Repository navigation
Add Lighthouse audit system with build tools and dashboard - #3
Conversation
WalkthroughSplits the monolithic runner into a TypeScript project: adds build/release tooling, ESM CLI bundling, TypeScript config and dev tooling, a modular Lighthouse runner, report ingestion/aggregation, dashboard generator (HTML/CSS/JS), and optional HTML image compression. Changes
Sequence Diagram(s)sequenceDiagram
participant CLI as CLI (lighthouse-runner.mjs)
participant Entrypoint as Entrypoint (src/index.ts → main)
participant Config as Config Loader (src/config.ts)
participant Runner as runLighthouse (src/lighthouse.ts)
participant Chrome as Headless Chrome
participant LighthouseLib as Lighthouse lib
participant FS as Filesystem (runsDir)
participant Extract as Data Extractor (src/data-extract.ts)
participant Dashboard as Visual Report Generator (src/dashboard)
participant Compressor as Image Compressor (src/compression.ts)
CLI->>Entrypoint: start
Entrypoint->>Config: load config & args
alt compress mode
Entrypoint->>Compressor: compressAllReports(quality)
Compressor-->>Entrypoint: done
else run mode
loop iterations
Entrypoint->>Runner: runLighthouse(url, iteration, mode, runId)
Runner->>Chrome: launch
Runner->>LighthouseLib: execute audit
LighthouseLib-->>Runner: LHR JSON
Runner->>FS: write cleaned LHR + .summary.json
Runner->>Chrome: kill
end
Entrypoint->>Extract: extractDataFromReports()
Extract-->>Dashboard: StatsData
Dashboard-->>FS: write visual-summary.html
end
Entrypoint-->>CLI: exit
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 14
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (18)
src/data-extract.ts-133-133 (1)
133-133:⚠️ Potential issue | 🟡 MinorMode fallback loses granularity and may mis-group legacy files.
When
summary.modeis missing, this collapses every non-desktop variant to'mobile', somobile-4gandmobile-wififiles (which are first-class inconfig.emulations) get merged together inextracted.urls[url].modesRaw. If any legacy report lacks themodefield, the averaged dashboard series will silently blend two different network profiles.Prefer decoding from the filename when possible, or skip entries without a resolvable mode. At minimum, log a warning when the fallback fires.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` at line 133, The fallback that sets mode to 'mobile' when summary.mode is missing is collapsing distinct emulation profiles; update the logic in the data extraction where mode is assigned (currently using summary.mode or file.includes('-desktop-')) to attempt decoding the full mode token from the filename (e.g., detect "mobile-4g", "mobile-wifi", "desktop" by parsing the filename pattern) and only fallback to a single generic value if you cannot resolve any token; if you still must fallback, do not merge profiles silently—either skip the entry or emit a warning via the logger that the filename could not resolve a mode so maintainers know why an entry was dropped/merged; ensure changes affect how entries are placed into extracted.urls[url].modesRaw and reference config.emulations to validate allowed mode strings.src/data-extract.ts-138-149 (1)
138-149:⚠️ Potential issue | 🟡 Minor
timestampfallback toDate.now()corrupts historical ordering.If a summary is written without
timestamp, this assigns the current extraction time, which then drives both the temporal-proximity grouping (LEGACY_GROUP_GAP_MS) and the finalresult.sort((a, b) => a.timestamp - b.timestamp). The net effect is that a historical report silently jumps to "now", clusters with unrelated current runs, and appears as the most recent data point on the dashboard.Safer options:
- Fall back to the file's
mtime(fs.statSync(...).mtimeMs).- Parse the timestamp out of the filename if your naming scheme encodes it.
- Skip the file with a warning instead of fabricating a timestamp.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` around lines 138 - 149, The runData construction currently assigns timestamp: summary.timestamp || Date.now(), which fabricates “now” when a summary lacks a timestamp; change this to derive the timestamp from the file's modification time (e.g., use fs.statSync(filePath).mtimeMs) or, if stat fails, log a warning and skip the file rather than using Date.now(); update the timestamp assignment in the runData object (where summary.timestamp is read) and add an fs import and error handling around stat to avoid corrupting ordering and grouping (references: runData, summary.timestamp).tsconfig.json-19-19 (1)
19-19:⚠️ Potential issue | 🟡 MinorStale reference to
build.ts.The build entry is
build.js(perpackage.jsonscripts andbuild.jsitself), notbuild.ts. TypeScript silently ignores the nonexistent path, so it's not a hard error, but the include is misleading. Either drop"build.ts"frominclude, or—if you want to typecheck the build script—rename it tobuild.tsand addallowJsaccordingly.- "include": ["src/**/*", "build.ts"], + "include": ["src/**/*"],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tsconfig.json` at line 19, The tsconfig include array contains a stale reference "build.ts" that doesn't exist; update the tsconfig.json include (or rename the script) so it's accurate: either remove "build.ts" from the "include" array, or convert the build script to TypeScript (rename to build.ts) and enable allowJs/adjust compilerOptions accordingly so the build script is actually type-checked; target the "include" entry in tsconfig.json and the build script file referenced by package.json scripts to make the change.src/index.ts-47-47 (1)
47-47:⚠️ Potential issue | 🟡 MinorExit non-zero on failure.
main().catch(console.error)swallows the error and the process exits with code 0, so CI (e.g., the newrelease.ymlor any wrapper script) cannot detect Lighthouse runner failures.-main().catch(console.error); +main().catch((err) => { + console.error(err); + process.exit(1); +});🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index.ts` at line 47, Current use of main().catch(console.error) logs errors but leaves the process exit code as 0; update the top-level invocation (the main() call in src/index.ts) to handle promise rejection by logging the error and exiting non‑zero — e.g., replace the simple catch with a handler that calls console.error with the error and then process.exit(1) so CI detects failures.src/index.ts-16-16 (1)
16-16:⚠️ Potential issue | 🟡 Minor
args.runIterationscan beNaN.In
src/config.ts,runIterationsis set viaparseInt(arg.split('=')[1])— passing--runs=abc(or--runs=) yieldsNaN, notundefined, so this branch keepsNaN. Downstreamiterations > 0evaluatesfalseand the loop is silently skipped, which is confusing for the user.Consider guarding with
Number.isFinite:- const iterations = args.runIterations !== undefined ? args.runIterations : config.iterations; + const iterations = Number.isFinite(args.runIterations as number) + ? (args.runIterations as number) + : config.iterations;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index.ts` at line 16, The current assignment to iterations uses args.runIterations which may be NaN; change the guard to check for a finite number (e.g. use Number.isFinite(args.runIterations)) and fall back to config.iterations when it's not a valid number, and emit a clear warning or error via the existing logger if fallback occurs; update the assignment that sets iterations (the line referencing args.runIterations and config.iterations) so it only accepts finite numeric values.src/dashboard/index.ts-7-12 (1)
7-12:⚠️ Potential issue | 🟡 MinorEnsure
config.baseDirexists before writing, and note duplicate path logic.
- If
config.baseDirdoes not exist (e.g., fresh checkout where no iterations were run beforegenerateVisualReportis invoked),fs.writeFileSyncwill throwENOENT. Create the directory first.- The same
path.join(config.baseDir, 'visual-summary.html')is also computed insidegenerateHtmlinsrc/dashboard/template.ts:88. The value computed there appears unused in the returned HTML, so consider removing one of the duplicates to keep a single source of truth.🛡️ Proposed hardening
export function generateVisualReport(statsData: StatsData): void { const reportPath = path.join(config.baseDir, 'visual-summary.html'); const htmlContent = generateHtml(statsData); + fs.mkdirSync(config.baseDir, { recursive: true }); fs.writeFileSync(reportPath, htmlContent); console.log(`\n✨ Refined Premium Dashboard updated: ${reportPath}`); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/index.ts` around lines 7 - 12, generateVisualReport currently writes to path.join(config.baseDir, 'visual-summary.html') without ensuring config.baseDir exists and duplicates the same join inside generateHtml; fix by ensuring the directory exists (create config.baseDir with fs.mkdirSync(..., { recursive: true }) or similar) before calling fs.writeFileSync in generateVisualReport, and deduplicate the path logic by removing the unused path.join in generateHtml (or refactor generateHtml to accept the fully-resolved reportPath) so the file path is computed in one place (referencing generateVisualReport, config.baseDir and generateHtml to locate the changes).src/dashboard/client.ts-449-456 (1)
449-456:⚠️ Potential issue | 🟡 MinorTooltip can render
undefinedfor out-of-range url indices.
urlLabels[d.x]has no fallback here (unlike the axis callback on line 440). Ifd.xever exceedsurlLabels.length - 1(e.g., data/label mismatch), the tooltip prints"Score: 82 undefined (Click for details)".Details
- return 'Score: ' + d.y + ' ' + urlLabels[d.x] + ' (Click for details) '; + return 'Score: ' + d.y + ' ' + (urlLabels[d.x] || '') + ' (Click for details) ';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 449 - 456, The tooltip label callback currently uses urlLabels[d.x] without bounds checking, causing "undefined" if d.x is out of range; update the tooltip's label callback (the function assigned under tooltip.callbacks.label that reads const d = ctx.raw) to guard against invalid indices and use the same fallback as the axis callback (e.g., check d.x is a number within 0..urlLabels.length-1 and if not use a default like 'Unknown URL' or an empty string) so the returned string never contains "undefined".justfile-40-41 (1)
40-41:⚠️ Potential issue | 🟡 Minor
lighthouse-runner.mjs.mapis never produced.
build.jssetssourcemap: false(line 15), so there's no map file to remove. Either enable sourcemaps in the build or drop the stale filename fromclean.🧹 Proposed fix
# Clean build artifacts clean: - rm -f lighthouse-runner.mjs lighthouse-runner.mjs.map + rm -f lighthouse-runner.mjs🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@justfile` around lines 40 - 41, The clean target in the justfile references lighthouse-runner.mjs.map which is never generated because build.js sets sourcemap: false; update either build.js (change the bundler option sourcemap: false to sourcemap: true in the build configuration) to produce the .map file, or remove the stale lighthouse-runner.mjs.map entry from the clean recipe in the justfile so the clean target only removes files actually produced.src/lighthouse.ts-182-194 (1)
182-194:⚠️ Potential issue | 🟡 MinorMissing
runIdbreaks run-group correlation indata-extract.
runLighthouse(url, i, mode)is called without the optionalrunIdargument.src/data-extract.tsgroupAndAveragefirst groups by explicitrunIdand only falls back to temporal proximity for legacy entries. Without arunIdper pass, every new run is grouped by timestamp alone (LEGACY_GROUP_GAP_MS), which is fragile for slow audits or interleaved URLs. Passi(or a monotonically increasing counter) asrunIdto make grouping deterministic.Details
- await runLighthouse(url, i, mode); + await runLighthouse(url, i, mode, i);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 182 - 194, The loop calls runLighthouse(url, i, mode) without supplying a runId, so groupAndAverage in src/data-extract.ts (which groups by explicit runId before falling back to LEGACY_GROUP_GAP_MS) cannot deterministically correlate runs; modify the invocation in the Round Robin loop to pass a stable runId (e.g., the pass counter i or a dedicated monotonically increasing counter) to runLighthouse (ensure the runLighthouse signature/forwarding is updated where necessary) so each pass across config.urls and config.emulations is recorded with that runId for deterministic grouping.src/lighthouse.ts-43-77 (1)
43-77:⚠️ Potential issue | 🟡 MinorChrome instance can leak on early failure.
chromeLauncher.launchhappens before thetryblock. If any synchronous code between the launch (line 47) and thetryon line 77 throws (e.g.,getUrlHash(url)on an invalid URL),chrome.kill()will never run and the headless process will linger. Move the launch inside thetry, or put an immediatetry/finallyaround the whole body.🛡️ Proposed fix
export async function runLighthouse(url: string, iteration: number, mode: string, runId?: number): Promise<void> { const chrome = await chromeLauncher.launch({ chromePath: 'chromium', chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'], }); - - const isDesktop = mode === 'desktop'; - ... - console.log(`\n🚀 [${mode.toUpperCase()}] Audit: ${url} (ID: ${urlHash})`); - try { + const isDesktop = mode === 'desktop'; + ... + console.log(`\n🚀 [${mode.toUpperCase()}] Audit: ${url} (ID: ${urlHash})`); const runnerResult = await lighthouse(url, options, configObj);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 43 - 77, The chromeLauncher.launch call in runLighthouse is outside the try/finally so a thrown error (e.g., from getUrlHash) can leak the Chrome process; move the launch into the try block or wrap the entire function body in a try/finally so that chromeLauncher.launch(...) (and the returned chrome instance) is created within the try and chrome.kill() is always invoked in the finally; ensure references to chrome from chromeLauncher.launch and chrome.kill are preserved and handle the case where chrome may be undefined (e.g., check chrome before calling kill).src/dashboard/client.ts-391-412 (1)
391-412:⚠️ Potential issue | 🟡 MinorZoom/pan callbacks assigned after chart creation won't take effect immediately.
Setting
trendsChart.options.plugins.zoom.zoom.onZoomand.pan.onPanafter the chart is instantiated requires callingtrendsChart.update('none')afterward for the changes to register in chartjs-plugin-zoom v2.2.0. Without this, the callbacks won't fire on the user's first direct zoom/pan interaction. Recommend either declaring the callbacks in the initialnew Chart(...)options object, or explicitly callingtrendsChart.update('none')after both callback assignments (after line 412).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 391 - 412, The zoom/pan callbacks assigned to trendsChart after instantiation (trendsChart.options.plugins.zoom.zoom.onZoom and trendsChart.options.plugins.zoom.pan.onPan) won't register until the chart is updated; after assigning both callbacks, call trendsChart.update('none') so they take effect immediately, or alternatively move these handlers into the initial new Chart(...) options object; keep using the same variables (isUpdatingChart, isUpdatingSlider, timelineSlider, updateVisibleLabels) in the handlers.lighthouse-runner.mjs-300-300 (1)
300-300:⚠️ Potential issue | 🟡 Minor
readdirSync(runsDir)will throw if the directory is missing.
compressAllReportsis invoked from the--compressbranch before any guard. BecauserunsDircreation is gated byfs.existsSyncat config init, it should exist, but a user running--compresson a fresh checkout without./reports/runs/(or with a custombaseDirthat was never audited) will get an unhandledENOENT. Guard withfs.existsSync(runsDir)and print a friendly "no reports to compress" message.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` at line 300, compressAllReports currently calls fs3.readdirSync(runsDir) which will throw ENOENT if runsDir doesn't exist; update compressAllReports to first check fs3.existsSync(runsDir) and if false log a friendly "no reports to compress" message (or similar) and return early. Locate the call site using the function name compressAllReports and the variable runsDir (where readdirSync is used) and add the existsSync guard and early return so running with --compress on a fresh checkout or custom baseDir does not crash.lighthouse-runner.mjs-484-486 (1)
484-486:⚠️ Potential issue | 🟡 MinorFalsy-check on metric values hides
0as "-".
metrics.tbt || "-"(and the(... || 0) / 1e3for FCP/LCP) treat a legitimate0value as missing data.TBT = 0is a real, desirable outcome; logging it as "-" is misleading. Same pattern should be fixed inbuildMetricsRowat lines 1684-1689 and ingetAggAvgat line 1266 (see separate comment). Prefermetrics.tbt != null ? ... : "-".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 484 - 486, The log and helper functions incorrectly use falsy-checks that convert legitimate zero metric values into "-" (e.g., the console.log line using metrics.tbt || "-", and similar patterns in buildMetricsRow and getAggAvg); update those checks to explicitly test for null/undefined (e.g., use metrics.tbt != null ? metrics.tbt : "-" and for fcp/lcp use (metrics.fcp != null ? metrics.fcp : 0) before dividing) so 0 is preserved; locate the console.log statement shown and the functions buildMetricsRow and getAggAvg and replace all "||" fallbacks used to detect missing metrics with null/undefined checks (!= null) to ensure zeros are treated as real values.lighthouse-runner.mjs-1820-1828 (1)
1820-1828:⚠️ Potential issue | 🟡 MinorExternal scripts loaded without Subresource Integrity.
Six
<script>and one<link>pull fromcdn.jsdelivr.net/cdnjs.cloudflare.comwith nointegrity=norcrossorigin=. If the dashboard HTML is ever hosted/shared, a compromised CDN could inject arbitrary code. Since the CDN URLs already pin exact versions, adding SRI hashes is straightforward and free. Consider generating SRI hashes at build time or, better, bundling these dependencies locally alongside the generated HTML so the report is fully self-contained and offline-viewable.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1820 - 1828, The external CDN <script> and <link> tags in the generated HTML (the Chart.js, chartjs-plugin-zoom, chartjs-chart-boxplot, noUiSlider CSS/JS, Luxon and chartjs-adapter-luxon tags shown in the diff) lack Subresource Integrity and crossorigin attributes; fix this by either (a) adding correct integrity="sha384-..." and crossorigin="anonymous" attributes for each CDN URL (generate SRI hashes at build time and inject them into the template that produces these tags), or (b) bundle these assets locally and update the template to reference local files instead so the report is self-contained/offline; update the HTML generation logic that emits those <script> and <link> tags accordingly.lighthouse-runner.mjs-1265-1268 (1)
1265-1268:⚠️ Potential issue | 🟡 Minor
getAggAvgcoerces missing categories to0, skewing downward.
d.map(r => (r.categories[key] || 0))counts a missing or0category as a real 0 in the average, pulling the radar chart toward the origin whenever some runs don't have a given category.averageGroup(line 107-108) already filtersnull/NaNcorrectly; mirror that here:const getAggAvg = (data, key) => { - const vals = data.flatMap(d => d.map(r => (r.categories[key] || 0))); - return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + const vals = data + .flatMap(d => d.map(r => r.categories[key])) + .filter(v => v != null && !Number.isNaN(v)); + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1265 - 1268, getAggAvg currently coerces missing category values to 0 via (r.categories[key] || 0), which skews the average; update getAggAvg to mirror averageGroup by extracting the values for key from each run, converting to Number, and filtering out null/undefined/NaN (or using Number.isFinite) before computing the mean so only real numeric values are averaged; reference getAggAvg and averageGroup when making the change.lighthouse-runner.mjs-1487-1488 (1)
1487-1488:⚠️ Potential issue | 🟡 Minor
Math.min(...arr)/Math.max(...arr)will throw on large datasets.
allTimestampsgrows with audit history; once it exceeds the engine's argument limit (~65-125k in V8 depending on stack), the spread invocation will throwRangeError: Maximum call stack size exceeded. Given this dashboard is intended to aggregate all historical runs, that ceiling is reachable. Use a linear reduce:- const fullMin = Math.min(...allTimestamps); - const fullMax = Math.max(...allTimestamps); + let fullMin = allTimestamps[0]; + let fullMax = allTimestamps[0]; + for (const t of allTimestamps) { + if (t < fullMin) fullMin = t; + if (t > fullMax) fullMax = t; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1487 - 1488, The spread calls Math.min(...allTimestamps) and Math.max(...allTimestamps) will throw for very large allTimestamps arrays; replace them with a linear scan (e.g., using Array.prototype.reduce or a simple for loop) to compute fullMin and fullMax so it works for arbitrarily large datasets. In the block where fullMin and fullMax are computed, iterate over allTimestamps (or reduce) to update min/max starting from Infinity/-Infinity and handle the empty-array case (return undefined or a sensible default) instead of using the spread. Ensure you update the variables named fullMin and fullMax where they are used elsewhere in the file so behavior remains identical.lighthouse-runner.mjs-1837-1854 (1)
1837-1854:⚠️ Potential issue | 🟡 MinorInvalid HTML:
<ul>cannot have<a>as direct children.The
<ul class="nav-links">contains four<a>elements directly, with no wrapping<li>. Browsers render it, but it fails HTML validation and can break accessibility tooling/screen readers that rely on proper list semantics. Wrap each link in an<li>:<ul class="nav-links"> - <a href="#overview" class="nav-item active"> - <svg .../> - <span>Overview</span> - </a> + <li><a href="#overview" class="nav-item active"> + <svg .../> + <span>Overview</span> + </a></li> ... </ul>🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1837 - 1854, The <ul class="nav-links"> uses <a> elements as direct children which is invalid HTML; wrap each anchor in a list item to restore proper list semantics and accessibility. Update the markup where <ul class="nav-links"> is defined (the nav-links block containing the four <a href="#overview" class="nav-item">, <a href="#metrics" class="nav-item">, <a href="#trends" class="nav-item">, and <a href="#runs" class="nav-item">) so each anchor is enclosed within an <li> element, preserving existing classes and SVG/span children.lighthouse-runner.mjs-1683-1706 (1)
1683-1706:⚠️ Potential issue | 🟡 Minor
buildMetricsRowdisplays0as "-" for every metric.Same bug pattern as in
lighthouse.tslog output:metrics.tbt ? metrics.tbt.toFixed(0) + "ms" : "-"treats0as missing. For CLS this is the most visible — a perfectcls: 0score is rendered as "-", not "0.000". Use nullish checks:- const fcp = metrics.fcp ? (metrics.fcp / 1e3).toFixed(2) + "s" : "-"; - const lcp = metrics.lcp ? (metrics.lcp / 1e3).toFixed(2) + "s" : "-"; - const tbt = metrics.tbt ? metrics.tbt.toFixed(0) + "ms" : "-"; - const cls = metrics.cls ? metrics.cls.toFixed(3) : "-"; - const si = metrics.si ? (metrics.si / 1e3).toFixed(2) + "s" : "-"; - const tti = metrics.tti ? (metrics.tti / 1e3).toFixed(2) + "s" : "-"; + const fmt = (v, f) => (v != null && !Number.isNaN(v)) ? f(v) : "-"; + const fcp = fmt(metrics.fcp, v => (v / 1e3).toFixed(2) + "s"); + const lcp = fmt(metrics.lcp, v => (v / 1e3).toFixed(2) + "s"); + const tbt = fmt(metrics.tbt, v => v.toFixed(0) + "ms"); + const cls = fmt(metrics.cls, v => v.toFixed(3)); + const si = fmt(metrics.si, v => (v / 1e3).toFixed(2) + "s"); + const tti = fmt(metrics.tti, v => (v / 1e3).toFixed(2) + "s");Also note
getGradeat line 1690-1695 has the sameif (!val)pitfall — a0value returns"none"instead of"good".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1683 - 1706, The formatting and grading logic in buildMetricsRow (and its inner getGrade) treats 0 as missing because it uses truthy checks like `metrics.tbt ? ... : "-"` and `if (!val) return "none"`, so change those checks to nullish checks (e.g., `val == null` or `val === null || val === undefined`) wherever you test metric presence (fcp, lcp, tbt, cls, si, tti) and in getGrade; this ensures real zero values (like cls === 0) are formatted and graded correctly while still treating null/undefined as missing.
🧹 Nitpick comments (13)
package.json (1)
18-18: Declaring"type": "commonjs"while shipping.mjsis a minor smell.The bundle output is ESM (
format: 'esm'inbuild.js); the.mjsextension forces Node to load it as ESM regardless of the package-level"type", so it works by accident. If there is no real CJS consumer in this repo, switching to"type": "module"(and then you could also drop the.mjsextension in favor of.js) would make the intent explicit and remove the contradiction withtsconfig.json's"module": "CommonJS"— which is only used fortsc --noEmittype-checking, but is still confusing.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 18, The package.json currently declares "type": "commonjs" which contradicts the bundle output (build.js uses format: 'esm') and shipping .mjs files; change the package manifest to "type": "module" to make ESM intent explicit, then update build outputs (drop the .mjs extension in the bundler/output config so artifacts are .js) and reconcile build/tsconfig settings by noting that tsconfig.json's "module": "CommonJS" is only for tsc type-checking—if desired, update tsconfig.json "module" to "ESNext"/"ES2020" to avoid confusion; ensure any code that imports these artifacts is compatible with ESM.src/data-extract.ts (1)
35-39: Defaulting missing category averages to0is a misleading score.When
vals.length === 0, the code emitsavgCategories[key] = 0, which is indistinguishable from a real "failing" Lighthouse score and will render as such on the dashboard. SinceIterationEntry.categoriesisRecord<string, number>you cannot usenullwithout a type change, but the practical choices are:
- Widen the type to
Record<string, number | null>(consistent withmetrics) and emitnullfor "no data"; let the template handle the missing case.- Or simply skip the key rather than emitting a zero so the dashboard shows "n/a".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` around lines 35 - 39, The current loop computes avgCategories for each key but assigns 0 when vals.length === 0, which hides "no data"; change the avgCategories type and assignment so missing averages are represented explicitly: update the declaration avgCategories: Record<string, number> to avgCategories: Record<string, number | null>, and inside the loop where you compute vals set avgCategories[key] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; reference avgCategories, catKeys, group and IterationEntry.categories when making these changes so downstream code/template can display "n/a" for nulls.src/utils.ts (1)
1-17: LGTM — minor note on MD5 and truncation.The utilities are clean. A couple of optional considerations:
getUrlHashuses MD5 truncated to 8 hex chars (~32-bit space). That is fine for non-security bucketing/labels, but beware of collisions if the URL set grows large. Considersha1/sha256or a longer slice if collisions would corrupt grouping downstream indata-extract.ts.- The
cryptoimport works, but since the output is bundled as ESM (esbuildformat: 'esm'), prefer the node-prefixed formimport { createHash } from 'node:crypto'for clearer Node builtin resolution.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/utils.ts` around lines 1 - 17, Change the crypto import to use the Node builtin prefix (import { createHash } from 'node:crypto') and update getUrlHash to use a stronger hash and longer truncation to reduce collisions (e.g., use 'sha256' via createHash('sha256') and slice the hex to 16 chars instead of 8); keep the same return type and behavior otherwise (function name getUrlHash) and leave getUrlLabel/sleep untouched.justfile (1)
31-33: Emptyiterationsdefault passes bare--runto the runner.When invoked as
just runwith the default empty string, the command expands tonode lighthouse-runner.mjs --runwith nothing after.src/config.tsonly parses--runwhenarr[i+1] !== undefined, so it silently falls back toconfig.iterations. Consider guarding the flag in the recipe to avoid surprising no-op behavior.♻️ Proposed fix
# Build and run with custom iterations run iterations='': - node build.js && node lighthouse-runner.mjs --run {{iterations}} + node build.js && node lighthouse-runner.mjs {{ if iterations != "" { "--run " + iterations } else { "" } }}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@justfile` around lines 31 - 33, The justfile recipe "run" currently passes a bare "--run" when iterations is the empty default, which causes src/config.ts's parser (checking arr[i+1] !== undefined for "--run") to ignore it; update the recipe so it only includes the "--run {{iterations}}" flag when iterations is non-empty (or set a sensible default like '1') to avoid emitting a trailing flag with no value; modify the "run" recipe in justfile to guard inclusion of the flag (or change iterations='' to iterations='1') so lighthouse-runner.mjs receives either no flag or a valid value that src/config.ts can parse.src/lighthouse.ts (1)
158-158: Persisting the full stats file after every audit is I/O-heavy.
fs.writeFileSync(dataPath, JSON.stringify(statsData, null, 2))is called on every single run inside a nestedurls × emulations × iterationsloop. As the dataset grows, each write re-serializes the entire accumulated history. Consider writing once per iteration pass (or only inmain()after each round), keeping the per-run side effect only for the individual*.summary.json/ LHR files.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` at line 158, The current code calls fs.writeFileSync(dataPath, JSON.stringify(statsData, null, 2)) inside the nested urls × emulations × iterations loop, causing expensive repeated full-file serializations; move the full stats persist out of the inner loop so only per-run artifacts (e.g., *.summary.json / LHR files) are written inside the loop and the aggregated statsData is written once per iteration pass or at the end of main(); locate the write call (fs.writeFileSync with dataPath and statsData) and change its placement to after the iteration/emulation loops (or in main() after each round) while keeping the existing per-run write behavior for summaries intact.src/dashboard/client.ts (1)
58-76:themeBtn.addEventListenerwill throw if the button is missing.If the template ever changes or the script loads before the DOM button exists,
themeBtnisnulland this line throws, halting the rest of the dashboard init (all charts below never render). A simple null guard avoids cascading UI breakage.Details
-themeBtn.addEventListener('click', () => setTheme(!body.classList.contains('light-mode'))); +themeBtn?.addEventListener('click', () => setTheme(!body.classList.contains('light-mode')));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 58 - 76, The code currently assumes themeBtn and themeText exist which can throw if DOM elements are missing; add null guards: only call themeBtn.addEventListener when themeBtn is non-null, and in setTheme guard use themeText (and body if needed) before accessing innerText/classList; also ensure the initial localStorage check that calls setTheme(true) can handle missing themeText by making setTheme tolerant of null elements; keep references to themeBtn, setTheme, themeText, and updateChartThemes when implementing these guards.src/config.ts (1)
70-84: Silent fallback for invalid--run/--qualityvalues.
--runwithout a numeric value (e.g.--run --compress) silently falls back toconfig.iterationsrather than erroring — easy to miss in CI.--quality 150is silently discarded (stays at 30) with no warning.- No validation of the
urls.jsonpayload: if it isn't an array of strings (loadedUrls: string[]), the runner will blindly iterate over unexpected values orfor…of-throw on a non-iterable.Minimal hardening would log a warning and exit non-zero on invalid input rather than pretending the flag wasn't there.
Details
const loaded = JSON.parse(fs.readFileSync(urlsPath, 'utf8')); - loadedUrls = loaded; + if (!Array.isArray(loaded) || !loaded.every((u) => typeof u === 'string')) { + console.error('❌ Fatal: urls.json must be a JSON array of strings.'); + process.exit(1); + } + loadedUrls = loaded;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 70 - 84, The CLI parsing currently silently ignores invalid or missing values for flags in args (runIterations, quality, compress) and the urls payload; update the parsing and loading logic to validate inputs and fail loudly: when parsing args, detect cases where --run or --quality are provided but the next token is missing, non-numeric, or out of range (quality not in 1–100) and emit a clear console.error warning and process.exit(1) instead of leaving defaults; likewise, when loading URLs (loadedUrls), validate that the parsed value is an Array of strings (each item typeof === 'string') and if not, log an error and exit non-zero. Reference the existing symbols args, runIterations, quality, compress and loadedUrls to locate where to add these checks. Ensure error messages include the offending flag/value so CI logs are actionable.src/dashboard/template.ts (1)
88-88: UnusedreportPathconstant.Declared but never referenced; the actual write happens in
src/dashboard/index.ts. Remove it.Details
export function generateHtml(statsData: StatsData): string { - const reportPath = `${config.baseDir}/visual-summary.html`; const urls = Object.keys(statsData.urls);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` at line 88, Remove the unused constant reportPath declared as reportPath = `${config.baseDir}/visual-summary.html` in the template file (the const named reportPath in src/dashboard/template.ts); delete the declaration line and ensure no other code in the file depends on it (the actual file write is handled in src/dashboard/index.ts), leaving no leftover references or unused imports.src/dashboard/styles.ts (1)
586-588: Empty CSS rule.The
.noUi-handle-upper .noUi-tooltiprule block is empty — either add the intended positioning or remove the rule to keep the stylesheet clean.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/styles.ts` around lines 586 - 588, The CSS contains an empty rule for .noUi-handle-upper .noUi-tooltip; either remove that empty selector block to keep the stylesheet clean or implement the intended tooltip positioning by adding the appropriate positioning properties (e.g., offsets/transform or left/right) inside the .noUi-handle-upper .noUi-tooltip rule so the right-handle tooltip is positioned to the left as intended.build.js (1)
28-34: Remove unreachable error-check branch.
esbuild.build()rejects the promise on build errors and throws aBuildFailureexception; it never resolves with aBuildResultcontaining populated errors. Lines 29-32 are unreachable dead code — all build failures will be caught by the.catch()handler at line 37. Remove the redundant error-check and simplify to directly log success on successful build completion.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` around lines 28 - 34, The code checks result.errors after calling esbuild.build(buildOptions) but esbuild.build rejects with a BuildFailure on errors and never returns a BuildResult with populated errors; remove the unreachable branch that inspects result.errors and the result variable altogether, so after await esbuild.build(buildOptions) simply log success (e.g., "Built successfully!") on completion and let the existing .catch() handler handle failures; update any references to result to avoid unused variable warnings.lighthouse-runner.mjs (3)
44-46:mkdirSyncruns as an import-time side effect.
runsDiris created eagerly during module initialization (init_config), before any argument parsing or command dispatch. That means evennode lighthouse-runner.mjs --help-style invocations (or tests importingconfig) will silently create./reports/runs/on disk. Prefer lazy creation inside the code paths that actually write into the directory (Lighthouse runs; extract), and skip it for the--compresspath which only reads existing files.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 44 - 46, The module currently creates the runsDir at import time in init_config using fs.mkdirSync(runsDir, { recursive: true }), causing side effects on simple imports; move that creation lazily into the actual code paths that write files (the Lighthouse run handler function(s) and the extract/write flow), and remove the import-time mkdirSync call so the --compress/read-only path only reads existing files; search for runsDir, init_config, and any functions that perform Lighthouse runs or extract/write operations and call fs.mkdirSync there just before the first write.
69-69: MD5 truncated to 8 hex chars will collide in practice.
getUrlHashkeeps only 8 hex chars (32 bits). By the birthday bound, collisions become likely at ~65k URLs — but more pragmatically, IDs of the form${urlHash}-${mode}-${timestamp}(line 371/447) rely onurlHashbeing unique across the user's URL set. Two distinct URLs sharing a prefix can silently overwrite each other's JSON/summary files because filenames are deterministic per (urlHash, mode, timestamp). Either keep the full digest, or use 12-16 hex chars to get a comfortable safety margin. MD5 itself is fine here since this is non-cryptographic, but considercrypto.createHash("sha1")to quiet future FIPS warnings.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` at line 69, getUrlHash currently truncates the MD5 digest to 8 hex chars causing likely collisions; update getUrlHash to return a longer hex slice (recommend 12–16 chars) or the full digest to reduce collision risk, and if desired switch the hash algorithm to "sha1" to avoid potential FIPS warnings—then ensure any code that builds filenames using the urlHash (e.g., the `${urlHash}-${mode}-${timestamp}` pattern) continues to work with the longer hash.
1-8: Committing a bundled output file is fragile.This file carries all of esbuild's
__esm/__commonJSscaffolding and re-declares everysrc/*module inline. Any bug fix must be made in the TypeScript sources and re-bundled, but the diff is being reviewed against the generated artifact, which makes it easy to drift, hard to diff meaningfully, and prone to overwriting fixes on the next rebuild.Consider either:
- Keeping
lighthouse-runner.mjsout of the repo (generate it at install/release time, e.g. via aprepare/buildnpm script) and distributing via annpmpackage or a release tarball, or- At minimum, adding a note (and a CI check) that this file must only be regenerated via
./build.jsand never hand-edited.All subsequent findings apply to the corresponding TypeScript sources; please fix there and re-bundle.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1 - 8, The committed bundle contains build scaffolding (__esm, __commonJS) and inlined compiled modules which should not be edited directly; instead, update the original TypeScript sources and re-run the bundler. Remove manual edits to the generated symbols (__esm, __commonJS) and ensure the build pipeline regenerates lighthouse-runner.mjs via the proper build step (e.g., ./build.js or the project's prepare/build npm script); add a repository note and CI check enforcing that lighthouse-runner.mjs is only generated by the build script, not hand-edited.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: c6d1e20c-1d33-4e86-86ca-d99057ae9b3f
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (18)
.github/workflows/release.ymlbuild.jsdevenv.nixjustfilelighthouse-runner.jslighthouse-runner.mjspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (7)
src/dashboard/template.ts (2)
88-88: UnusedreportPath.
reportPathis computed but never returned or referenced; writing the HTML to disk is handled bysrc/dashboard/index.ts. Safe to delete.♻️ Proposed change
export function generateHtml(statsData: StatsData): string { - const reportPath = `${config.baseDir}/visual-summary.html`; const urls = Object.keys(statsData.urls);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` at line 88, The variable reportPath declared as const reportPath = `${config.baseDir}/visual-summary.html` in template.ts is unused and should be removed; delete the reportPath declaration to eliminate dead code and ensure no other code references it (verify that only reportPath is removed and not any config usage), since writing the HTML is handled elsewhere (src/dashboard/index.ts) and nothing returns or uses reportPath.
13-22:calcAvgis dead code.Only
calcWeightedAvgis invoked bygenerateHtml. The simplecalcAvghelper has no callers and can be removed.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 13 - 22, The calcAvg function is unused dead code; remove the calcAvg function declaration from template.ts and any related exports or references so only calcWeightedAvg remains used by generateHtml, ensuring no remaining imports or tests refer to calcAvg and running tests/build to confirm no references remain.build.js (1)
15-15: Sourcemap generation disabled, butnpm run cleanremoveslighthouse-runner.mjs.map.The
cleanscript inpackage.json(line 13) removes a.mapfile that this build never produces. Either enablesourcemap(recommended for distributable binaries / stack traces) or drop the.mapentry from thecleanscript.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` at line 15, The build config currently sets sourcemap: false while the npm "clean" script deletes lighthouse-runner.mjs.map; either enable source maps by setting the sourcemap option to true in the build config (so the produced artifact matches the clean script) or remove the .map filename from the "clean" script in package.json; update the sourcemap setting (symbol: sourcemap) or adjust the "clean" npm script to stop removing lighthouse-runner.mjs.map so build and cleanup are consistent.src/lighthouse.ts (1)
49-51: Unused localisThrottled.
isThrottledis assigned but never read. Either drop it or use it in thethrottlingMethodbranch for readability.♻️ Proposed change
const isDesktop = mode === 'desktop'; const isWifi = mode === 'mobile-wifi'; - const isThrottled = mode === 'mobile-4g';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 49 - 51, The local variable isThrottled is declared but never used; either remove its declaration or use it to set throttlingMethod for clarity. Locate the mode checks around "const isDesktop = mode === 'desktop'; const isWifi = mode === 'mobile-wifi'; const isThrottled = mode === 'mobile-4g';" and either delete the isThrottled line, or reference isThrottled when computing throttlingMethod (the branch that currently uses mode) so throttlingMethod uses isThrottled instead of directly comparing mode for readability and to eliminate the unused variable warning.src/data-extract.ts (1)
132-132: Dead computation:urlHashis never used.
urlHashis computed on every iteration but never referenced.getUrlHashis imported only for this unused assignment — both can be removed.♻️ Proposed change
-import { getUrlHash, getUrlLabel, RunData, StatsData, IterationEntry } from './utils'; +import { getUrlLabel, RunData, StatsData, IterationEntry } from './utils';const url = summary.url || ''; - const urlHash = getUrlHash(url); const mode: string = summary.mode || (file.includes('-desktop-') ? 'desktop' : 'mobile');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` at line 132, Remove the dead computation of urlHash by deleting the unused assignment "const urlHash = getUrlHash(url)" in src/data-extract.ts and eliminating the now-unused getUrlHash import; before removing the import, search for other uses of getUrlHash in the module to avoid breaking consumers, and if getUrlHash has side effects ensure those are no longer required or refactored into an explicit call where needed.lighthouse-runner.mjs (1)
1-2126: Bundled artifact committed to git—recommend gitignoring and producing via CI only.
lighthouse-runner.mjsis a build output tracked in git with no.gitignoreentry. Since the release workflow already runsnpm run build:minifyto generate it, committing this artifact creates drift risk: every source fix requires a matching rebuild to keep the bundled version in sync, and reviewers must verify both the source and artifact. Instead, add to.gitignoreand let CI produce and release it.Also,
fixDoublePrefixesInReport(src/compression.ts) is exported but never called in the codebase. Either wire it into the compress pipeline or remove the unused export.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1 - 2126, The repo contains a committed build artifact (lighthouse-runner.mjs) that should be gitignored and generated by CI; remove the tracked file from the repo (git rm --cached), add its filename or the build output directory to .gitignore, and update CI/build docs to ensure npm run build:minify produces it during release; additionally, address the unused export fixDoublePrefixesInReport from src/compression.ts by either removing the exported function if not needed or integrating it into the compression pipeline (call it from compressReportImages or compressAllReports) so it is exercised by the existing compressAllReports flow.package.json (1)
13-13:cleanscript is not cross-platform.
rm -fis a POSIX command — it will fail on Windows CMD and plain PowerShell. For cross-platform compatibility, replace withrimraf:♻️ Proposed change
- "clean": "rm -f lighthouse-runner.mjs lighthouse-runner.mjs.map" + "clean": "rimraf lighthouse-runner.mjs lighthouse-runner.mjs.map"Then add
rimraftodevDependencies. Note:lighthouse-runner.mjs.mapis never generated becausebuild.jssetssourcemap: false.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 13, The package.json "clean" npm script currently uses the POSIX command "rm -f" which breaks on Windows; replace the "clean" script entry named "clean" to use a cross-platform tool (e.g., "rimraf") and add "rimraf" to devDependencies, and while updating the script remove or stop referencing "lighthouse-runner.mjs.map" if your build.js sets sourcemap:false so that the script only deletes actually generated artifacts (e.g., target files such as "lighthouse-runner.mjs").
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/dashboard/template.ts`:
- Around line 145-153: The summary object is pulling r.url and r.urlLabel from
IterationEntry (r) which don't exist, causing undefined links in the trends UI;
update the injected trendData so summary.url and summary.urlLabel use the outer
loop variables (url and the outer urlLabel if present) instead of
r.url/r.urlLabel while keeping id as r.id || r.fileName and other fields (mode,
timestamp, categories, metrics) unchanged—adjust the code that builds summary in
template.ts (the block creating summary for each r in
statsData.urls[url].modes[mode]) so showRunModal no longer receives undefined
href/text.
- Around line 60-85: buildMetricsRow treats zero-valued metrics as missing
because it uses truthy checks; update both the display computations and the
grading predicate to use nullish checks instead: in buildMetricsRow replace
expressions like metrics.tbt ? ... : '-' with checks for null/undefined (e.g.
metrics.tbt == null ? '-' : formattedValue) for fcp, lcp, tbt, cls, si, tti, and
change getGrade to test val == null (or val === null || val === undefined)
rather than !val so 0 is graded correctly; reference function buildMetricsRow
and its inner getGrade to locate and fix all instances.
In `@src/lighthouse.ts`:
- Around line 152-154: The console summary prints TBT using truthy fallback
which turns a legitimate 0 into '-'—update the logging expression that builds
the summary (the console.log using categories.performance, metrics.fcp,
metrics.lcp, metrics.tbt and fileName) to use the nullish coalescing operator
for TBT (metrics.tbt ?? '-') instead of the logical OR so zero is shown
correctly, matching the nullish checks used elsewhere in the file.
- Around line 162-192: The exported main() in lighthouse.ts is unused and calls
runLighthouse(url, i, mode) without a runId, which risks losing run grouping;
either remove the dead export or update main() to mirror src/index.ts by
generating a runId per iteration (e.g., using the same runId creation logic) and
pass it into runLighthouse(url, i, mode, runId), and ensure statsData handling
remains consistent; change the export accordingly (delete export if removing the
function or keep export after fixing signature) and update any callers if
needed.
---
Nitpick comments:
In `@build.js`:
- Line 15: The build config currently sets sourcemap: false while the npm
"clean" script deletes lighthouse-runner.mjs.map; either enable source maps by
setting the sourcemap option to true in the build config (so the produced
artifact matches the clean script) or remove the .map filename from the "clean"
script in package.json; update the sourcemap setting (symbol: sourcemap) or
adjust the "clean" npm script to stop removing lighthouse-runner.mjs.map so
build and cleanup are consistent.
In `@lighthouse-runner.mjs`:
- Around line 1-2126: The repo contains a committed build artifact
(lighthouse-runner.mjs) that should be gitignored and generated by CI; remove
the tracked file from the repo (git rm --cached), add its filename or the build
output directory to .gitignore, and update CI/build docs to ensure npm run
build:minify produces it during release; additionally, address the unused export
fixDoublePrefixesInReport from src/compression.ts by either removing the
exported function if not needed or integrating it into the compression pipeline
(call it from compressReportImages or compressAllReports) so it is exercised by
the existing compressAllReports flow.
In `@package.json`:
- Line 13: The package.json "clean" npm script currently uses the POSIX command
"rm -f" which breaks on Windows; replace the "clean" script entry named "clean"
to use a cross-platform tool (e.g., "rimraf") and add "rimraf" to
devDependencies, and while updating the script remove or stop referencing
"lighthouse-runner.mjs.map" if your build.js sets sourcemap:false so that the
script only deletes actually generated artifacts (e.g., target files such as
"lighthouse-runner.mjs").
In `@src/dashboard/template.ts`:
- Line 88: The variable reportPath declared as const reportPath =
`${config.baseDir}/visual-summary.html` in template.ts is unused and should be
removed; delete the reportPath declaration to eliminate dead code and ensure no
other code references it (verify that only reportPath is removed and not any
config usage), since writing the HTML is handled elsewhere
(src/dashboard/index.ts) and nothing returns or uses reportPath.
- Around line 13-22: The calcAvg function is unused dead code; remove the
calcAvg function declaration from template.ts and any related exports or
references so only calcWeightedAvg remains used by generateHtml, ensuring no
remaining imports or tests refer to calcAvg and running tests/build to confirm
no references remain.
In `@src/data-extract.ts`:
- Line 132: Remove the dead computation of urlHash by deleting the unused
assignment "const urlHash = getUrlHash(url)" in src/data-extract.ts and
eliminating the now-unused getUrlHash import; before removing the import, search
for other uses of getUrlHash in the module to avoid breaking consumers, and if
getUrlHash has side effects ensure those are no longer required or refactored
into an explicit call where needed.
In `@src/lighthouse.ts`:
- Around line 49-51: The local variable isThrottled is declared but never used;
either remove its declaration or use it to set throttlingMethod for clarity.
Locate the mode checks around "const isDesktop = mode === 'desktop'; const
isWifi = mode === 'mobile-wifi'; const isThrottled = mode === 'mobile-4g';" and
either delete the isThrottled line, or reference isThrottled when computing
throttlingMethod (the branch that currently uses mode) so throttlingMethod uses
isThrottled instead of directly comparing mode for readability and to eliminate
the unused variable warning.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 7a615d7b-26ce-4f36-948c-b9b56894d7c9
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (18)
.github/workflows/release.ymlbuild.jsdevenv.nixjustfilelighthouse-runner.jslighthouse-runner.mjspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
✅ Files skipped from review due to trivial changes (6)
- devenv.nix
- .github/workflows/release.yml
- tsconfig.json
- src/index.ts
- justfile
- src/utils.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/dashboard/index.ts
- src/dashboard/styles.ts
- src/config.ts
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (14)
src/dashboard/template.ts (3)
88-88: UnusedreportPath— dead code.
reportPathis computed here but never referenced ingenerateHtml. The actual write is done insrc/dashboard/index.ts(generateVisualReport). Remove this line to avoid confusion.♻️ Proposed fix
export function generateHtml(statsData: StatsData): string { - const reportPath = `${config.baseDir}/visual-summary.html`; const urls = Object.keys(statsData.urls);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` at line 88, Remove the unused dead variable reportPath (const reportPath = `${config.baseDir}/visual-summary.html`) from src/dashboard/template.ts: locate the declaration near the generateHtml function and delete it, since the actual write path is handled by generateVisualReport in src/dashboard/index.ts and reportPath is never referenced.
10-22:calcAvgis unused.
calcAvgis defined but never called in this module (onlycalcWeightedAvgis used downstream). Either remove it or, if it's intended for future use, mark/export it explicitly.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 10 - 22, calcAvg is defined but never used; either remove the function or export it if it's intended for external/future use. Find the calcAvg function in this file and either delete its definition or add an explicit export (e.g., export function calcAvg(...)) so it is available to downstream modules; ensure no other internal references (like calcWeightedAvg) depend on it before removal. If exporting, also update any index exports or documentation as needed to expose calcAvg to consumers.
302-307:overallModePerf[mode].toFixed(0)can read undefined if a mode has no averaged data.
allModesis derived fromstatsData.urls[...].modeskeys so every mode in the list has at least one URL entry — however, ifmodeDataAvg[mode].flat()is empty (e.g., all arrays are[]because onlymodesRawwas populated for that mode),calcWeightedAvgreturns0and this is safe. Still, consider guarding against missing keys defensively ((overallModePerf[mode] ?? 0).toFixed(0)) since this HTML is rendered from potentially-stalepersistent_stats.json.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 302 - 307, The template can throw when overallModePerf[mode] is undefined; update the rendering to defensively default missing mode values to 0 before formatting. Locate the map over allModes in template.ts that uses overallModePerf[mode].toFixed(0) and change the expression to use a nullish/default guard (e.g., overallModePerf[mode] ?? 0) so undefined entries from stale persistent_stats.json or missing calcWeightedAvg results are rendered as 0; keep the rest of the card markup and references to modeDataAvg/calcWeightedAvg intact.src/config.ts (2)
25-39:process.exit(1)at module load is an ergonomic hazard.Any importer of
./config— including tests, IDE type-checks, or downstream tools that simply want a type — will hard-kill the process ifurls.jsonis missing. Prefer throwing an error and letting the CLI entrypoint translate it into a friendly message +process.exit:♻️ Proposed fix
try { loadedUrls = JSON.parse(fs.readFileSync('./urls.json', 'utf8')); } catch { - console.error( - '❌ Fatal: Could not load urls.json. Please create it with an array of URLs.', - ); - process.exit(1); + throw new Error('Could not load urls.json. Please create it with an array of URLs.'); }Have the top-level entry (
src/index.ts) catch and translate toconsole.error+exit(1).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 25 - 39, The module-level config loader currently calls process.exit(1) when urls.json cannot be read; change this to throw a descriptive Error instead so importing modules (and tests) aren't killed: in the config loader that builds loadedUrls (refer to variables loadedUrls and urlsPath and the try/catch blocks) remove process.exit and rethrow or throw a new Error with a clear message about missing/invalid urls.json; then update the application entrypoint (e.g., src/index.ts) to catch that Error and perform console.error plus process.exit(1) there so only the CLI terminates while imports remain safe.
41-50: Hardcoded runtime config discourages reuse.
iterations,delay,baseDir,reportSubDir,dataFile,emulationsare all baked in. Users cannot change them via env or a config file without editing source and rebuilding. Consider sourcing overridable fields from env vars (LHR_ITERATIONS,LHR_BASE_DIR, etc.) or alighthouse-runner.config.jsonalongsideurls.json.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 41 - 50, The config object currently hardcodes runtime values (fields iterations, delay, baseDir, reportSubDir, dataFile, emulations, chromePath) which prevents overrides; modify the export const config in src/config.ts to load these fields from environment variables (e.g., LHR_ITERATIONS, LHR_DELAY, LHR_BASE_DIR, LHR_REPORT_SUBDIR, LHR_DATA_FILE, LHR_EMULATIONS, CHROME_PATH) with sensible defaults falling back to the existing literals, and add support to read an optional lighthouse-runner.config.json (merging/overriding the env/defaults) so users can override values without rebuilding; ensure the unique symbol config and loadedUrls remain used and validate/parses emulations (CSV/JSON) and numeric fields (iterations, delay) when constructing the final Config object.lighthouse-runner.mjs (1)
1-10:lighthouse-runner.mjsis a build artifact — consider not committing it.This entire ~2000-line file is the esbuild bundle of
src/*.ts(exact same sources, just mangled/reconciled). Committing it duplicates every source change in the diff, causes noisy merge conflicts, and creates risk that the.mjsdrifts out of sync withsrc/. The release workflow already builds it on demand (per the PR description) andpackage.jsonhasbuild/cleanscripts for it.Recommend:
- Add
lighthouse-runner.mjs(and*.mjs.map) to.gitignore.- Remove it from version control.
- Let the release workflow be the sole producer.
If you deliberately want to ship a prebuilt binary for npm consumers (since
mainpoints at this file), consider usingprepublishOnlyto build on publish instead of committing the artifact.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` around lines 1 - 10, The committed lighthouse-runner.mjs file is a generated esbuild bundle (and likely its .mjs.map) that should not be tracked; remove lighthouse-runner.mjs (and any *.mjs.map) from the repository, add patterns for them to .gitignore, and update the repo so the file is deleted from version control (git rm --cached or equivalent) so the release workflow remains the sole producer; if you intend to ship a prebuilt artifact for npm consumers instead of relying on the CI build, configure package.json to build on publish (e.g., add a prepublishOnly script) and ensure package.json "main" continues to point to the built artifact.src/lighthouse.ts (2)
82-87:|| 0on category scores misses the "audit not run" case.Unlike the
keyMetricsblock below (which correctly switched to??),safeGet(lhr.categories.xxx, 'score') || 0still uses||. A Lighthouse score of exactly0(category completely failed) coalesces to0too so the user-visible behavior is identical — but ifscoreis evernull(category present but not scored) you also get0, which is a legitimate Lighthouse score. For consistency with lines 90-99, switch to?? 0, or propagatenulland render it as "-" like the metrics do.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 82 - 87, The categories object is using fallback with || which conflates a real 0 score and a null "not scored" value; update the expressions in the categories block (the keys performance, accessibility, 'best-practices', seo using safeGet(lhr.categories.X, 'score')) to use the nullish-coalescing operator (?? 0) or alternatively propagate null and render it as "-" to match the keyMetrics behavior — e.g., replace each "safeGet(..., 'score') || 0" with "safeGet(..., 'score') ?? 0" (or return null and handle display downstream).
49-51:isThrottledis computed but unused.
isThrottled = mode === 'mobile-4g'is assigned but never referenced (thethrottlingMethodternary usesisDesktop || isWifidirectly). Harmless but confusing — drop it or use it in place of the ternary for readability.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 49 - 51, The variable isThrottled (const isThrottled = mode === 'mobile-4g') is defined but not used; either remove that unused declaration or use it to make the throttlingMethod decision clearer: replace the inline ternary that now checks (isDesktop || isWifi) with a condition that uses isThrottled (or refactor to use all three flags consistently: isDesktop, isWifi, isThrottled) in the throttlingMethod computation so the intent is explicit and no unused variable remains (look for throttlingMethod and the consts isDesktop/isWifi/isThrottled in src/lighthouse.ts).package.json (2)
1-28: Missingenginesfield.With
target: 'node24'inbuild.js, consumers or CI on older Node versions (e.g., Node 20 LTS) may silently get runtime errors on transpiled-to-node24 syntax (e.g., newer syntactic features, Promise helpers). Add anenginesentry sonpm/pnpmwarn on install:♻️ Proposed fix
"license": "ISC", "type": "commonjs", + "engines": { + "node": ">=24" + }, "dependencies": {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` around lines 1 - 28, The package.json is missing an engines field while build.js targets 'node24', so add an "engines" entry to package.json (e.g., set node to a minimum version compatible with build.js output such as ">=24.0.0") to surface warnings on install; update package.json's top-level metadata (near name/version/description) to include the engines object so CI and consumers running older Node (e.g., Node 20) get a clear warning.
23-27: Update@types/nodeto match runtime target.
build.jstargetsnode24but@types/nodeis pinned to^20.10.0, which lags significantly behind. Thetypecheckscript will miss newly-added Node 22/24 APIs and provide inaccurate type information. Bump@types/nodeto^25(currently at 25.6.0) to match the runtime target.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` around lines 23 - 27, The `@types/node` devDependency is pinned to ^20.10.0 while build.js targets node24, causing typechecks (the typecheck script) to miss Node 22/24 APIs; update the devDependencies entry for "@types/node" to a Node 25 range (e.g., ^25 or the current 25.6.0) in package.json so TypeScript sees the correct runtime types and rerun the typecheck/build to verify no type errors.build.js (1)
22-35: Watch mode never reports build errors.In
--watchmode, any initial or subsequent rebuild errors will be emitted by esbuild on its internal logger but this script never inspects them and never exits non-zero. If a CI job is somehow invoked with--watch, or if a developer relies on process exit codes, silent failures are possible. Also noteresult.errors.length > 0is largely redundant becauseesbuild.buildthrows on error by default (caught below), so the branch is effectively dead code. Consider replacing with a thrown-error assertion or removing the manual check.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` around lines 22 - 35, The watch branch never inspects rebuild errors and never exits non-zero; change the watch logic in build() to use esbuild's watch API with an onRebuild handler (e.g., call esbuild.build({...buildOptions, watch:{onRebuild(error,result){ if (error) { console.error('Build error:', error); process.exit(1); } else { console.log('Rebuilt successfully'); }}}})) instead of relying on esbuild.context(). Also remove the redundant manual check of result.errors after esbuild.build (or replace it with proper try/catch that logs the thrown error and exits non-zero) so initial build failures are surfaced.src/dashboard/client.ts (3)
349-352:Math.min(...allTimestamps)/Math.max(...)is fragile for large trend datasets.Spread into
Math.min/Math.maxwill blow the call stack at engine‑dependent argument limits (≈100k entries in most browsers). Unlikely today, but this array grows withruns × urls × modes, so a simple reducer avoids the ceiling entirely.♻️ Safer reduction
- const fullMin = Math.min(...allTimestamps); - const fullMax = Math.max(...allTimestamps); + let fullMin = Infinity, fullMax = -Infinity; + for (const t of allTimestamps) { + if (t < fullMin) fullMin = t; + if (t > fullMax) fullMax = t; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 349 - 352, The current computation of fullMin/fullMax uses Math.min(...allTimestamps) and Math.max(...allTimestamps), which can exceed argument limits for large arrays; replace these spread calls with safe reductions over allTimestamps (e.g., reduce to compute min and max) so the engine never receives a giant argument list; update the block that creates allTimestamps and the assignments to fullMin and fullMax (referenced as trendData, allTimestamps, fullMin, fullMax) to use Array.prototype.reduce to derive the minimum and maximum values instead of using spread with Math.min/Math.max.
381-389: Nit: use thevaluesargument instead of re‑querying the slider.noUiSlider passes the numeric values (already via your
format.to) to theupdatehandler. Re‑reading viatimelineSlider.get()[0]is redundant and forces an extra object allocation per drag tick. Also,handleis unused.♻️ Proposed tweak
- timelineSlider.on('update', (values, handle) => { + timelineSlider.on('update', (values) => { if (isUpdatingSlider) return; isUpdatingChart = true; - const min = timelineSlider.get()[0]; - const max = timelineSlider.get()[1]; + const min = Number(values[0]); + const max = Number(values[1]); trendsChart.zoomScale('x', { min, max }); updateVisibleLabels(min, max); isUpdatingChart = false; });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 381 - 389, The update handler for timelineSlider re-queries the slider instead of using the provided values and also ignores the handle param; change the handler registered in timelineSlider.on('update', ...) to early-return on isUpdatingSlider, set isUpdatingChart, read min = values[0] and max = values[1] (using the values argument passed into the handler), call trendsChart.zoomScale('x', { min, max }) and updateVisibleLabels(min, max), then clear isUpdatingChart; remove the redundant timelineSlider.get() calls and the unused handle variable to avoid extra allocations per tick.
391-412: MoveonZoom/onPancallbacks into initial chart options — assigning them after construction is not supported by chartjs-plugin-zoom.The callbacks are assigned to
trendsChart.options.plugins.zoom.zoom.onZoomand...pan.onPanafternew Chart(...)completes (lines 391–412), with notrendsChart.update()call. With chartjs-plugin-zoom v2.2.0, callbacks must be in the initial options object at chart creation; modifying them after construction is not directly supported and may not be picked up by the plugin. If they silently fail, the slider↔chart sync becomes one-way (slider → chart only).Move these callbacks into the
options.plugins.zoomobject during chart initialization at line 246, alongsidezoom.wheel,zoom.pinch,pan.enabled, etc.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 391 - 412, The onZoom/onPan handlers are being assigned to trendsChart.options.plugins.zoom.zoom.onZoom and trendsChart.options.plugins.zoom.pan.onPan after chart construction which chartjs-plugin-zoom doesn't pick up; move these callbacks into the initial options.plugins.zoom object passed to new Chart(...) (alongside existing zoom.wheel, zoom.pinch and pan.enabled) so they are registered at creation, keeping their logic that checks isUpdatingChart/isUpdatingSlider, sets isUpdatingSlider, reads chart.scales.x.min/max, updates timelineSlider.set([min,max], true, true) if timelineSlider, calls updateVisibleLabels(min, max), and resets isUpdatingSlider.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/compression.ts`:
- Around line 7-29: The function fixDoublePrefixesInReport is never invoked, so
either remove it or call it from the public flow; update compressAllReports to
invoke fixDoublePrefixesInReport for each report file (the same places where
compressReportImages is called) so the double-prefix and minified `ye` fixes run
during a normal --compress run, or if you prefer deletion, remove
fixDoublePrefixesInReport and its tests/usages; reference compressAllReports and
compressReportImages to locate where to wire the call.
- Around line 65-67: The execFileSync call that runs ImageMagick currently uses
the literal 'convert' which breaks on Windows (where convert is a system tool);
update the call site that invokes execFileSync (the call using tmpIn, tmpOut and
useQuality) to instead use a configurable binary: read a new IMAGEMAGICK_BIN env
var (falling back to 'magick' then 'convert') and pass that binary name into
execFileSync so Windows users get 'magick' by default but callers can override;
ensure the same argument order (input, '-quality', String(useQuality), output)
is preserved when switching to the configured binary.
In `@src/dashboard/client.ts`:
- Around line 246-259: The current borderDash logic in the Chart dataset
generation (inside the trendData.flatMap/allModes mapping) uses
mode.includes('wifi') || mode.includes('desktop') which dashes 'wifi' and
'desktop' but leaves 'mobile-4g' solid; if the intended behavior is to dash
everything except the primary 'mobile-4g' line, change the condition to an
explicit exclusion (e.g., borderDash when mode !== 'mobile-4g'), or conversely
if only desktop should be dashed make it mode.includes('desktop') only; update
the borderDash expression in the dataset mapping so it matches the intended
visual distinction.
- Around line 58-76: The code assumes DOM elements exist and will throw if they
don't; update the theme initialization to null-check themeBtn and themeText
before using them (references: themeBtn, themeText, setTheme, updateChartThemes)
— e.g., guard the addEventListener and any direct property access
(themeText.innerText) with if checks or optional chaining so missing elements
are skipped without throwing, and only call updateChartThemes when it exists/is
safe to run; apply the same defensive pattern for other DOM lookups mentioned
(e.g., .close-modal, `#runModal`) to prevent a single missing element from
breaking dashboard initialization.
- Around line 478-479: Replace the unsafe innerHTML assignment that interpolates
summary.url with DOM API construction: in the block that sets title.textContent
and url.innerHTML, create an anchor element via document.createElement('a'), set
its href to a validated/normalized summary.url (allow only http/https or prepend
a safe scheme), set anchor.textContent to the URL or summary.id, and set and rel="noopener noreferrer" via element properties; then
append the anchor to the url container and remove any use of innerHTML. Ensure
you reference the existing variables title, url, and summary.url when making
these changes.
- Around line 125-146: The radar chart uses hardcoded labels that can desync
from INJECT_categoryKeys (catKeys); replace the literal ['Performance',
'Accessibility', 'Best Practices', 'SEO'] in the Chart data.labels with a
dynamic mapping using the existing catLabels mapping (i.e., labels:
catKeys.map(k => catLabels[k])); ensure catLabels is available at the top of
this file (move or import the catLabels definition so it sits alongside
catKeys/modeDataAvg/modeDataRaw) and keep the datasets generation using catKeys
as-is so labels and data stay in sync.
In `@src/data-extract.ts`:
- Around line 35-39: avgCategories currently uses 0 as a sentinel when no valid
values exist, making missing data indistinguishable from a real 0 score; change
the type of avgCategories from Record<string, number> to Record<string, number |
null> and set avgCategories[key] = null when vals.length === 0 (keeping the
numeric average when vals has entries), and update any downstream consumer of
avgCategories to handle nulls appropriately (look for usages of avgCategories,
catKeys, and the grouping logic that computes vals to locate where to adjust
typing and handling).
- Around line 133-144: The heuristic that sets mode (currently using
summary.mode || (file.includes('-desktop-') ? 'desktop' : 'mobile')) collapses
all legacy mobile files into 'mobile' which doesn't match downstream mode
buckets; update the logic that sets mode (the variable assigned and the
RunData.mode field) to also detect '-mobile-4g' and '-mobile-wifi' in file
(e.g., check file.includes('-mobile-4g') and file.includes('-mobile-wifi')) and
map them to 'mobile-4g' and 'mobile-wifi' respectively, with a sensible default
mobile mode if none match so legacy runs align with the pipeline's allModes
expected values.
In `@src/lighthouse.ts`:
- Line 41: The module-level persistence using dataPath and statsData (loaded by
loadStatsData and mutated in runLighthouse by appending RunData to
statsData.runs) is redundant and unsafe; either remove all reads/writes of
dataPath and stop mutating statsData in runLighthouse so that only the
per-report summary files remain the source of truth (and delete loadStatsData,
statsData, and any writes), or if you must keep it make runLighthouse write
atomically (write to tmp + rename), enforce bounded growth/deduping of
statsData.runs and modesRaw, and update the dashboard to read this file
(extractDataFromReports must be changed to use the persisted StatsData). Pick
one approach and apply consistently across loadStatsData, statsData, dataPath,
runLighthouse, and extractDataFromReports.
---
Nitpick comments:
In `@build.js`:
- Around line 22-35: The watch branch never inspects rebuild errors and never
exits non-zero; change the watch logic in build() to use esbuild's watch API
with an onRebuild handler (e.g., call esbuild.build({...buildOptions,
watch:{onRebuild(error,result){ if (error) { console.error('Build error:',
error); process.exit(1); } else { console.log('Rebuilt successfully'); }}}}))
instead of relying on esbuild.context(). Also remove the redundant manual check
of result.errors after esbuild.build (or replace it with proper try/catch that
logs the thrown error and exits non-zero) so initial build failures are
surfaced.
In `@lighthouse-runner.mjs`:
- Around line 1-10: The committed lighthouse-runner.mjs file is a generated
esbuild bundle (and likely its .mjs.map) that should not be tracked; remove
lighthouse-runner.mjs (and any *.mjs.map) from the repository, add patterns for
them to .gitignore, and update the repo so the file is deleted from version
control (git rm --cached or equivalent) so the release workflow remains the sole
producer; if you intend to ship a prebuilt artifact for npm consumers instead of
relying on the CI build, configure package.json to build on publish (e.g., add a
prepublishOnly script) and ensure package.json "main" continues to point to the
built artifact.
In `@package.json`:
- Around line 1-28: The package.json is missing an engines field while build.js
targets 'node24', so add an "engines" entry to package.json (e.g., set node to a
minimum version compatible with build.js output such as ">=24.0.0") to surface
warnings on install; update package.json's top-level metadata (near
name/version/description) to include the engines object so CI and consumers
running older Node (e.g., Node 20) get a clear warning.
- Around line 23-27: The `@types/node` devDependency is pinned to ^20.10.0 while
build.js targets node24, causing typechecks (the typecheck script) to miss Node
22/24 APIs; update the devDependencies entry for "@types/node" to a Node 25
range (e.g., ^25 or the current 25.6.0) in package.json so TypeScript sees the
correct runtime types and rerun the typecheck/build to verify no type errors.
In `@src/config.ts`:
- Around line 25-39: The module-level config loader currently calls
process.exit(1) when urls.json cannot be read; change this to throw a
descriptive Error instead so importing modules (and tests) aren't killed: in the
config loader that builds loadedUrls (refer to variables loadedUrls and urlsPath
and the try/catch blocks) remove process.exit and rethrow or throw a new Error
with a clear message about missing/invalid urls.json; then update the
application entrypoint (e.g., src/index.ts) to catch that Error and perform
console.error plus process.exit(1) there so only the CLI terminates while
imports remain safe.
- Around line 41-50: The config object currently hardcodes runtime values
(fields iterations, delay, baseDir, reportSubDir, dataFile, emulations,
chromePath) which prevents overrides; modify the export const config in
src/config.ts to load these fields from environment variables (e.g.,
LHR_ITERATIONS, LHR_DELAY, LHR_BASE_DIR, LHR_REPORT_SUBDIR, LHR_DATA_FILE,
LHR_EMULATIONS, CHROME_PATH) with sensible defaults falling back to the existing
literals, and add support to read an optional lighthouse-runner.config.json
(merging/overriding the env/defaults) so users can override values without
rebuilding; ensure the unique symbol config and loadedUrls remain used and
validate/parses emulations (CSV/JSON) and numeric fields (iterations, delay)
when constructing the final Config object.
In `@src/dashboard/client.ts`:
- Around line 349-352: The current computation of fullMin/fullMax uses
Math.min(...allTimestamps) and Math.max(...allTimestamps), which can exceed
argument limits for large arrays; replace these spread calls with safe
reductions over allTimestamps (e.g., reduce to compute min and max) so the
engine never receives a giant argument list; update the block that creates
allTimestamps and the assignments to fullMin and fullMax (referenced as
trendData, allTimestamps, fullMin, fullMax) to use Array.prototype.reduce to
derive the minimum and maximum values instead of using spread with
Math.min/Math.max.
- Around line 381-389: The update handler for timelineSlider re-queries the
slider instead of using the provided values and also ignores the handle param;
change the handler registered in timelineSlider.on('update', ...) to
early-return on isUpdatingSlider, set isUpdatingChart, read min = values[0] and
max = values[1] (using the values argument passed into the handler), call
trendsChart.zoomScale('x', { min, max }) and updateVisibleLabels(min, max), then
clear isUpdatingChart; remove the redundant timelineSlider.get() calls and the
unused handle variable to avoid extra allocations per tick.
- Around line 391-412: The onZoom/onPan handlers are being assigned to
trendsChart.options.plugins.zoom.zoom.onZoom and
trendsChart.options.plugins.zoom.pan.onPan after chart construction which
chartjs-plugin-zoom doesn't pick up; move these callbacks into the initial
options.plugins.zoom object passed to new Chart(...) (alongside existing
zoom.wheel, zoom.pinch and pan.enabled) so they are registered at creation,
keeping their logic that checks isUpdatingChart/isUpdatingSlider, sets
isUpdatingSlider, reads chart.scales.x.min/max, updates
timelineSlider.set([min,max], true, true) if timelineSlider, calls
updateVisibleLabels(min, max), and resets isUpdatingSlider.
In `@src/dashboard/template.ts`:
- Line 88: Remove the unused dead variable reportPath (const reportPath =
`${config.baseDir}/visual-summary.html`) from src/dashboard/template.ts: locate
the declaration near the generateHtml function and delete it, since the actual
write path is handled by generateVisualReport in src/dashboard/index.ts and
reportPath is never referenced.
- Around line 10-22: calcAvg is defined but never used; either remove the
function or export it if it's intended for external/future use. Find the calcAvg
function in this file and either delete its definition or add an explicit export
(e.g., export function calcAvg(...)) so it is available to downstream modules;
ensure no other internal references (like calcWeightedAvg) depend on it before
removal. If exporting, also update any index exports or documentation as needed
to expose calcAvg to consumers.
- Around line 302-307: The template can throw when overallModePerf[mode] is
undefined; update the rendering to defensively default missing mode values to 0
before formatting. Locate the map over allModes in template.ts that uses
overallModePerf[mode].toFixed(0) and change the expression to use a
nullish/default guard (e.g., overallModePerf[mode] ?? 0) so undefined entries
from stale persistent_stats.json or missing calcWeightedAvg results are rendered
as 0; keep the rest of the card markup and references to
modeDataAvg/calcWeightedAvg intact.
In `@src/lighthouse.ts`:
- Around line 82-87: The categories object is using fallback with || which
conflates a real 0 score and a null "not scored" value; update the expressions
in the categories block (the keys performance, accessibility, 'best-practices',
seo using safeGet(lhr.categories.X, 'score')) to use the nullish-coalescing
operator (?? 0) or alternatively propagate null and render it as "-" to match
the keyMetrics behavior — e.g., replace each "safeGet(..., 'score') || 0" with
"safeGet(..., 'score') ?? 0" (or return null and handle display downstream).
- Around line 49-51: The variable isThrottled (const isThrottled = mode ===
'mobile-4g') is defined but not used; either remove that unused declaration or
use it to make the throttlingMethod decision clearer: replace the inline ternary
that now checks (isDesktop || isWifi) with a condition that uses isThrottled (or
refactor to use all three flags consistently: isDesktop, isWifi, isThrottled) in
the throttlingMethod computation so the intent is explicit and no unused
variable remains (look for throttlingMethod and the consts
isDesktop/isWifi/isThrottled in src/lighthouse.ts).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f279dfbf-4d19-4e20-95af-1fadddf1ca46
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (18)
.github/workflows/release.ymlbuild.jsdevenv.nixjustfilelighthouse-runner.jslighthouse-runner.mjspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
✅ Files skipped from review due to trivial changes (4)
- tsconfig.json
- justfile
- src/dashboard/styles.ts
- src/utils.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- devenv.nix
- .github/workflows/release.yml
- src/index.ts
- src/dashboard/index.ts
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (1)
lighthouse-runner.mjs (1)
469-469:⚠️ Potential issue | 🟠 Major
jsExecTimeis wired to the wrong Lighthouse audit.
third-party-summary'snumericValuerepresents main-thread blocking time caused by third-party code, not overall JavaScript execution time. The correct audit for main-thread JS execution time (parse + compile + execute) isbootup-time.🔧 Fix (in `src/lighthouse.ts`, then rebuild)
- jsExecTime: safeGet(lhr.audits["third-party-summary"], "numericValue") ?? null, + jsExecTime: safeGet(lhr.audits["bootup-time"], "numericValue") ?? null,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lighthouse-runner.mjs` at line 469, jsExecTime is reading the wrong audit; replace the reference to lhr.audits["third-party-summary"] with lhr.audits["bootup-time"] so jsExecTime uses the bootup-time audit's numericValue (e.g., jsExecTime: safeGet(lhr.audits["bootup-time"], "numericValue") ?? null), keeping the same safeGet fallback pattern and ensuring the change is applied where jsExecTime is set.
🧹 Nitpick comments (6)
src/data-extract.ts (1)
35-44: Minor: useavgValuesconsistently for categories.Lines 37–38 reimplement the same null/NaN-aware averaging that
avgValuesalready provides (used for metrics on line 43). Reusing it keeps the two code paths in sync if the filter ever changes (e.g., clamping 0–100, treatingNaNdifferently).♻️ Proposed tweak
const avgCategories: Record<string, number | null> = {}; for (const key of catKeys) { - const vals = group.map((e) => e.categories[key]).filter((v) => v != null && !isNaN(v as number)) as number[]; - avgCategories[key] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; + avgCategories[key] = avgValues(group.map((e) => e.categories[key])); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` around lines 35 - 44, The category averaging block duplicates null/NaN-aware logic; replace the manual loop that builds avgCategories using the ad-hoc filter with calls to avgValues like the metrics path does: for each key in catKeys call avgValues(group.map(e => e.categories[key])) and assign to avgCategories[key] (preserving the Record<string, number | null> type), so categories and metrics use the same null/NaN handling implemented by avgValues.src/compression.ts (1)
88-95: Skip the rewrite when nothing was replaced.
content.replace(imgRegex, …)always runs andfs.writeFileSync(filePath, content)is called even whenreplacementsis empty /imagesProcessed === 0. Rewriting an untouched multi‑MB HTML report is wasteful I/O and also pointlessly bumps the file’s mtime on repeated--compressruns over already-processed reports.♻️ Suggested tweak
- content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); - - fs.writeFileSync(filePath, content); - const compressedSize = Buffer.byteLength(content, 'utf8'); + if (replacements.size > 0) { + content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); + fs.writeFileSync(filePath, content); + } + const compressedSize = Buffer.byteLength(content, 'utf8');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` around lines 88 - 95, The current code always runs content = content.replace(imgRegex, ...) and fs.writeFileSync(filePath, content) even when no images were replaced; modify the logic in the function that uses imgRegex, replacements and imagesProcessed so that if imagesProcessed === 0 (or replacements.size === 0) you skip running the write and avoid updating the file mtime—return { originalSize, compressedSize: originalSize, imagesProcessed: 0 } (or compute compressedSize only when writing) and only call fs.writeFileSync(filePath, content) when at least one replacement occurred; keep existing return shape and use the same variables (content, imgRegex, replacements, imagesProcessed, filePath) to gate the write.src/index-logic.ts (1)
8-42: LGTM.Flow control (compress short-circuit → optional iterations → extract → visualize) is clear, and
runLighthousehandles its own failures so a single bad URL doesn't abort the round-robin.One tiny nit you can ignore: the
await sleep(config.delay * 1000)on line 32 also fires after the very last(url, mode)combination of the last pass, adding a gratuitous delay before extraction/dashboard rendering. Easy fix if you care: skip the sleep wheni === iterations && url === lastUrl && mode === lastMode.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index-logic.ts` around lines 8 - 42, The final sleep after the last run causes an unnecessary delay; update the main() loop so it skips calling await sleep(config.delay * 1000) when about to finish all work (i.e., when i === iterations and the current url is the last entry of config.urls and the current mode is the last entry of config.emulations). Modify the nested loops in main() to compute or check the lastUrl/lastMode (from config.urls and config.emulations) and only call sleep(...) when not on that final combination; keep runLighthouse(...) behavior unchanged.src/config.ts (1)
54-56:LHR_EMULATIONSvalues are not validated against theConfig.emulationsunion.
process.env.LHR_EMULATIONS.split(',').map(s => s.trim())returns arbitrary strings, and theas anycast silently bypasses the narrow'mobile-4g' | 'mobile-wifi' | 'desktop'union. A typo likeLHR_EMULATIONS=desktops,mobile-4gpasses through and later hitssrc/lighthouse.tswhereisDesktop/isWifi/isThrottledare all false, producing a misconfigured audit rather than a clear error.Consider filtering to known values (and erroring on an empty result) instead of casting away the type.
🛠️ Sketch
- emulations: (process.env.LHR_EMULATIONS - ? process.env.LHR_EMULATIONS.split(',').map(s => s.trim()) - : fileConfig.emulations || ['mobile-4g', 'mobile-wifi', 'desktop']) as any, + emulations: (() => { + const allowed = ['mobile-4g', 'mobile-wifi', 'desktop'] as const; + const raw = process.env.LHR_EMULATIONS + ? process.env.LHR_EMULATIONS.split(',').map((s) => s.trim()) + : fileConfig.emulations || allowed; + const filtered = raw.filter((m: string): m is typeof allowed[number] => + (allowed as readonly string[]).includes(m), + ); + if (filtered.length === 0) { + throw new Error(`No valid emulations. Allowed: ${allowed.join(', ')}`); + } + return filtered; + })(),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 54 - 56, The emulations assignment accepts arbitrary strings and uses "as any", bypassing the Config.emulations union; update the logic that builds emulations from process.env.LHR_EMULATIONS to split/trim then filter against the allowed set ['mobile-4g','mobile-wifi','desktop'] (reference the emulations property and LHR_EMULATIONS env var), remove the unsafe "as any" cast, and if the filtered result is empty (and no fileConfig.emulations present) throw a clear error so mis-typed values fail fast rather than producing a misconfigured run used later by lighthouse checks (e.g., isDesktop/isWifi/isThrottled).src/dashboard/client.ts (1)
505-522:inpis listed inmetricLabelsbut never produced upstream.
metricLabelsincludesinp: 'INP', butsrc/lighthouse.tsdoesn’t extract INP intosummary.metrics, so the filterfilter(([k]) => metricLabels[k])will never surface it, and the thresholds ternary on line 515 has noinpbranch either. Either add aninpaudit extraction inlighthouse.ts(Lighthouse exposesexperimental-interaction-to-next-paint) and a threshold case here, or drop theinpentry to avoid confusion.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 505 - 522, metricLabels includes "inp" but summary.metrics never contains INP (so the UI never shows it) and the thresholds branch in the metrics rendering (in the metrics.map block) has no case for "inp"; either (A) add INP extraction in src/lighthouse.ts (pull the "experimental-interaction-to-next-paint" audit into summary.metrics as "inp") and then add an INP thresholds branch in the metrics rendering logic (treating it like other timing metrics with appropriate ms-to-s conversion and thresholds), or (B) remove "inp: 'INP'" from the metricLabels object to avoid advertising a metric that isn’t produced; update the rendering logic (the metrics.map block that references metricLabels and the thresholds ternary) accordingly to keep branches consistent.src/dashboard/template.ts (1)
13-22: Remove the unusedcalcAvgfunction.
calcAvgis exported but has no callers in the codebase — onlycalcWeightedAvgis used for aggregations. This is dead code that can be safely removed to reduce the module size.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 13 - 22, Remove the dead exported function calcAvg from the module: locate the exported function named calcAvg in template.ts and delete its declaration and export so it is no longer part of the public API; ensure no other code references calcAvg (the review states only calcWeightedAvg is used) and run a quick compile/typecheck to verify there are no remaining references.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.gitignore:
- Line 32: The .gitignore line "urls.jsonlighthouse-runner.mjs" is malformed and
thus not ignoring the build artifact; open .gitignore and split that token into
two separate entries so the committed file stays tracked and the build artifact
is ignored: add a line containing "urls.json" (to keep it committed) and a
separate line containing "lighthouse-runner.mjs" (to ignore the generated file).
In `@lighthouse-runner.mjs`:
- Around line 1724-1733: buildMetricsRow currently injects the raw url into
three HTML sinks (title, href, link text) creating stored XSS/open-redirect
risk; fix by sanitizing and escaping the URL before interpolation in the
template: validate the scheme (allow only http/https), reject or normalize any
other schemes like javascript:, and HTML-escape characters (&, <, >, ", ') for
the title and link text, while using a safe/hardcoded href value or the
validated URL for href only if it passed validation; implement this logic inside
buildMetricsRow (use the local url variable and produce
escapedTitle/escapedText/safeHref values) and apply the same sanitization
wherever statsData.urls[...] or other user-controlled URLs are injected into
templates.
In `@src/config.ts`:
- Around line 8-17: Remove the unused required property by editing the Config
interface: delete the dataFile: string declaration from the exported Config type
(reference symbol: Config) so the config object literal no longer violates the
interface; ensure no other code references Config.dataFile and, if any remain,
either remove those references or make the property optional with dataFile?:
string instead.
In `@src/dashboard/client.ts`:
- Around line 256-266: The legend suffix generation using
mode.charAt(0).toUpperCase() collapses 'mobile-4g' and 'mobile-wifi' into the
same "(M)"; update the label generation inside the trendData.flatMap call to use
a clearer abbreviation mapping (e.g., an object map for modes like {desktop:
'D', 'mobile-4g': 'M-4G', 'mobile-wifi': 'M-WiFi'}) or transform the mode string
with mode.replace('mobile-', 'M-').toUpperCase() before appending, so labels
created in the datasets array (the code that builds label: d.label + ' (' + … +
')') produce distinct suffixes for mobile-4g and mobile-wifi while leaving
getModeColor(mode) and borderDash logic unchanged.
In `@src/data-extract.ts`:
- Around line 144-155: The RunData build accepts summary.categories/metrics
as-is which can be malformed; in extractDataFromReports where runData is
constructed (the runData object in src/data-extract.ts), validate/coerce
summary.categories and summary.metrics before assigning: ensure categories
contains the keys "performance", "accessibility", "best-practices", and "seo"
(and that each key is an object or null) and that metrics has the expected
metric keys, otherwise either skip this summary (log a warning) or fill missing
keys with null/default shapes so downstream consumers (template.ts reading
r.categories.performance, calcWeightedAvg, radar chart, etc.) never see
undefined; implement the check/ coercion right before creating runData and use
those validated objects in the runData assignment.
In `@src/lighthouse.ts`:
- Line 97: The jsExecTime field is currently pulled from
lhr.audits['third-party-summary'], which is incorrect; change it to use
lhr.audits['bootup-time'].numericValue instead (fallback to null the same way as
before) so jsExecTime reflects the "JavaScript execution time" audit; update the
reference in the mapping where jsExecTime is assigned and ensure safeGet or the
same null-coalescing logic is used with 'bootup-time' rather than
'third-party-summary'.
---
Duplicate comments:
In `@lighthouse-runner.mjs`:
- Line 469: jsExecTime is reading the wrong audit; replace the reference to
lhr.audits["third-party-summary"] with lhr.audits["bootup-time"] so jsExecTime
uses the bootup-time audit's numericValue (e.g., jsExecTime:
safeGet(lhr.audits["bootup-time"], "numericValue") ?? null), keeping the same
safeGet fallback pattern and ensuring the change is applied where jsExecTime is
set.
---
Nitpick comments:
In `@src/compression.ts`:
- Around line 88-95: The current code always runs content =
content.replace(imgRegex, ...) and fs.writeFileSync(filePath, content) even when
no images were replaced; modify the logic in the function that uses imgRegex,
replacements and imagesProcessed so that if imagesProcessed === 0 (or
replacements.size === 0) you skip running the write and avoid updating the file
mtime—return { originalSize, compressedSize: originalSize, imagesProcessed: 0 }
(or compute compressedSize only when writing) and only call
fs.writeFileSync(filePath, content) when at least one replacement occurred; keep
existing return shape and use the same variables (content, imgRegex,
replacements, imagesProcessed, filePath) to gate the write.
In `@src/config.ts`:
- Around line 54-56: The emulations assignment accepts arbitrary strings and
uses "as any", bypassing the Config.emulations union; update the logic that
builds emulations from process.env.LHR_EMULATIONS to split/trim then filter
against the allowed set ['mobile-4g','mobile-wifi','desktop'] (reference the
emulations property and LHR_EMULATIONS env var), remove the unsafe "as any"
cast, and if the filtered result is empty (and no fileConfig.emulations present)
throw a clear error so mis-typed values fail fast rather than producing a
misconfigured run used later by lighthouse checks (e.g.,
isDesktop/isWifi/isThrottled).
In `@src/dashboard/client.ts`:
- Around line 505-522: metricLabels includes "inp" but summary.metrics never
contains INP (so the UI never shows it) and the thresholds branch in the metrics
rendering (in the metrics.map block) has no case for "inp"; either (A) add INP
extraction in src/lighthouse.ts (pull the
"experimental-interaction-to-next-paint" audit into summary.metrics as "inp")
and then add an INP thresholds branch in the metrics rendering logic (treating
it like other timing metrics with appropriate ms-to-s conversion and
thresholds), or (B) remove "inp: 'INP'" from the metricLabels object to avoid
advertising a metric that isn’t produced; update the rendering logic (the
metrics.map block that references metricLabels and the thresholds ternary)
accordingly to keep branches consistent.
In `@src/dashboard/template.ts`:
- Around line 13-22: Remove the dead exported function calcAvg from the module:
locate the exported function named calcAvg in template.ts and delete its
declaration and export so it is no longer part of the public API; ensure no
other code references calcAvg (the review states only calcWeightedAvg is used)
and run a quick compile/typecheck to verify there are no remaining references.
In `@src/data-extract.ts`:
- Around line 35-44: The category averaging block duplicates null/NaN-aware
logic; replace the manual loop that builds avgCategories using the ad-hoc filter
with calls to avgValues like the metrics path does: for each key in catKeys call
avgValues(group.map(e => e.categories[key])) and assign to avgCategories[key]
(preserving the Record<string, number | null> type), so categories and metrics
use the same null/NaN handling implemented by avgValues.
In `@src/index-logic.ts`:
- Around line 8-42: The final sleep after the last run causes an unnecessary
delay; update the main() loop so it skips calling await sleep(config.delay *
1000) when about to finish all work (i.e., when i === iterations and the current
url is the last entry of config.urls and the current mode is the last entry of
config.emulations). Modify the nested loops in main() to compute or check the
lastUrl/lastMode (from config.urls and config.emulations) and only call
sleep(...) when not on that final combination; keep runLighthouse(...) behavior
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a8f5c960-a124-415f-aa19-445dc3a1c151
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (21)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jslighthouse-runner.mjspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.jsonurls.json
✅ Files skipped from review due to trivial changes (5)
- urls.json
- .github/workflows/release.yml
- src/dashboard/index.ts
- src/utils.ts
- justfile
🚧 Files skipped from review as they are similar to previous changes (6)
- tsconfig.json
- devenv.nix
- src/dashboard/styles.ts
- src/index.ts
- package.json
- build.js
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (7)
package.json (3)
16-16:cleanscript is not cross-platform.
rm -ffails on Windows (cmd/PowerShell) where contributors may runnpm run clean. Consider a Node-based approach orrimrafto keep the script portable.♻️ Proposed change
- "clean": "rm -f lighthouse-runner.mjs lighthouse-runner.mjs.map", + "clean": "node -e \"require('node:fs').rmSync('lighthouse-runner.mjs',{force:true});require('node:fs').rmSync('lighthouse-runner.mjs.map',{force:true})\"",🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 16, The "clean" npm script uses a POSIX-only command ("rm -f") which breaks on Windows; replace it with a cross-platform approach by updating the "clean" script in package.json (the "clean" script entry) to use a portable tool such as "rimraf lighthouse-runner.mjs lighthouse-runner.mjs.map" and add rimraf as a devDependency (or alternatively implement a small Node-based script and point the "clean" script to it), ensuring npm scripts and package.json dependencies are updated accordingly.
8-17: Consider declaringbinandfilesfor a CLI-oriented package.
build.jsprepends a#!/usr/bin/env nodeshebang tolighthouse-runner.mjs, strongly suggesting CLI usage — but nobinentry is exposed, sonpx lighthouse-runner/ global install won't work. Additionally, without afileswhitelist,npm publishwill shipsrc/,build.js, TS configs, etc., bloating the tarball. Both are optional, but worth considering before the next publish (prepublishOnlyalready guarantees a fresh build).♻️ Proposed change
"main": "lighthouse-runner.mjs", + "bin": { + "lighthouse-runner": "lighthouse-runner.mjs" + }, + "files": [ + "lighthouse-runner.mjs" + ], "scripts": {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` around lines 8 - 17, Add a "bin" entry and a "files" whitelist to package.json so the package installs as a CLI and only ships the built artifacts: add a "bin" mapping pointing the CLI name (e.g., "lighthouse-runner") to "lighthouse-runner.mjs" (ensuring build.js still writes the shebang and output file is executable) and add a "files" array that includes the runtime build outputs (e.g., "lighthouse-runner.mjs", "lighthouse-runner.mjs.map") plus relevant docs like "README.md" to avoid publishing source/fixtures/tsconfigs; keep "prepublishOnly" as-is to ensure build before publish.
22-22:"type": "commonjs"contradicts the ESM build output.
build.jsemitsformat: 'esm'and the TS sources use ESMimport/export. The.mjsextension currently forces ESM regardless of this field, so things work today — but the declaration is misleading and brittle: any future change to emit.js(or a consumer inspectingtype) will silently flip the artifact to CJS and break execution. Recommend aligning the field with the actual module system.♻️ Proposed change
- "type": "commonjs", + "type": "module",Note:
build.jsusesrequire()and would need to be renamed tobuild.cjsor converted to ESM if this change is made.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 22, package.json declares "type": "commonjs" which contradicts your ESM outputs (build.js sets format: 'esm' and TS uses import/export); change the package.json "type" field to "module" to align with emitted ESM artifacts and avoid silently shipping CJS. After changing to "module", update the build script: either rename the existing build.js (which uses require()) to build.cjs or rewrite build.js as ESM (use import instead of require) so the build tooling remains runnable under the new package "type". Ensure references to build.js in npm scripts or CI are updated to the new filename or ESM module entry.src/compression.ts (1)
88-93: Avoid rewriting the report when no images were replaced.When
replacementsis empty (every converted image turned out larger, or all matches were small-WebPcontinues),contentis unchanged yetfs.writeFileSync(filePath, content)still rewrites the file, bumping mtime and doing unnecessary I/O per-report. Gate the write on whether anything actually changed:♻️ Proposed fix
- content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); - - fs.writeFileSync(filePath, content); - const compressedSize = Buffer.byteLength(content, 'utf8'); + if (replacements.size > 0) { + content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); + fs.writeFileSync(filePath, content); + } + const compressedSize = Buffer.byteLength(content, 'utf8');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` around lines 88 - 93, The code always calls fs.writeFileSync(filePath, content) and recalculates compressedSize even when no image replacements occurred, causing unnecessary rewrites; change the logic around the content = content.replace(imgRegex, (m) => replacements.get(m) ?? m) to detect whether any replacement actually occurred (e.g., compare the new string to the original or track a flag while replacing) and only call fs.writeFileSync(filePath, content) and compute compressedSize when content changed; reference the existing variables imgRegex, replacements, content, filePath, and compressedSize to implement this gate.src/dashboard/template.ts (1)
140-148: DRY opportunity:calcWeightedAvgis called six times per(url, mode)pair in two places.The weighted-metrics computation is duplicated verbatim between the
modeMetricsbuild (lines 140‑147) andmetricsRows(lines 224‑230). Each invocation re-derivesnewestTsand weights from scratch on the sameentriesarray, so you do 12 passes when 2 would do. A small helper tightens both call sites and fixes the repetition:♻️ Suggested helper
const METRIC_KEYS = ['fcp', 'lcp', 'tbt', 'cls', 'si', 'tti'] as const; const weightedMetrics = (entries: IterationEntry[]) => Object.fromEntries(METRIC_KEYS.map((k) => [k, calcWeightedAvg(entries, 'metrics', k)]));Then
modeMetrics[mode] = urls.map(u => weightedMetrics(statsData.urls[u].modes[mode] || []))and themetricsRowsbranch becomesconst avg = weightedMetrics(entries).Also applies to: 218-234
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 140 - 148, The code repeatedly calls calcWeightedAvg six times per (url, mode) in both modeMetrics and metricsRows causing redundant passes; introduce a helper (e.g., METRIC_KEYS constant and a weightedMetrics(entries) function) that maps the six metric keys to their calcWeightedAvg results and returns an object, then replace the inline repeated calls in modeMetrics assignment and in the metricsRows branch (where entries is used) with a single call to weightedMetrics(statsData.urls[url].modes[mode] || []) / weightedMetrics(entries) so each entries array is scanned once and both call sites reuse the helper.src/lighthouse.ts (1)
52-58:logLevel: 'info'produces very noisy Lighthouse output.Lighthouse's
'info'log level prints a continuous stream of status lines for every audit — across N URLs × M emulations × K iterations that's a large volume of terminal noise that buries the per-run summary on line 129. Consider'error'(or'warn') as the default and exposing it via CLI flag if verbose output is ever wanted.♻️ Proposed change
- logLevel: 'info', + logLevel: 'error',🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 52 - 58, The default Lighthouse log level is too verbose; change the options object so options.logLevel uses a quieter default (e.g., 'error' or 'warn') instead of 'info', and wire a CLI flag or environment variable (e.g., --log-level or LIGHTHOUSE_LOG_LEVEL) into the code that builds the options so callers can opt into verbose output; update the construction of the options constant (the options object where logLevel is set) to read the flag/env and fall back to the quieter default.src/data-extract.ts (1)
22-58:averageGroupearly return losesiterationCount: 1intent only for single-entry groups, and unconditionally resetsiteration.Two small consistency items in this helper:
- Line 24 returns
{ ...group[0], iterationCount: 1 }— butgroup[0]was built upstream withiteration: 1, so that value is already 1 (fine). However, for multi-entry groups (line 50) you hardcodeiteration: 1into the averaged output. If iteration numbers carry meaning elsewhere, consider preserving the range (e.g.iterationCount) rather than masquerading it as iteration 1.- Line 47 computes the "last entry" via
reduce((a, b) => a.timestamp > b.timestamp ? a : b)which picks the max-timestamp entry, not the last by position. Since inputs togroupAndAverageare sorted by timestamp (line 94 for legacy; runId groups are insertion-ordered), the result is correct today, butgroup[group.length - 1]after sort would be cheaper and more obviously intentional.Non-blocking; just flagging since the
iteration: 1assignment is load-bearing for grouping elsewhere.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` around lines 22 - 58, averageGroup currently hardcodes iteration: 1 for multi-entry groups and finds the last entry via reduce; change it to preserve the original iteration baseline and use a clearer/cheaper last-element lookup: remove the hardcoded iteration: 1 and instead set iteration to group[0].iteration (or another intended baseline) while keeping iterationCount: group.length, and replace lastEntry = group.reduce(...) with lastEntry = group[group.length - 1] so the function (averageGroup) preserves iteration semantics and uses the actual last item by position.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 5-7: The package.json currently has a mismatch between the
engines.node field and the TypeScript ambient types: update either the
"engines": { "node": ">=24.0.0" } to ">=25.0.0" if you intend to use
`@types/node`@^25.x, or change the devDependency "@types/node" to ^24 so types
match Node 24; modify the package.json entry for engines.node or the
"@types/node" version accordingly and run install/type-check to verify
alignment.
In `@src/dashboard/client.ts`:
- Around line 508-509: Remove the dead INP entry or wire INP end-to-end;
specifically either delete the inp: 'INP' key from metricLabels (the constant
used when rendering metrics in the metrics.innerHTML code that filters
Object.entries(summary.metrics)) so the UI only shows metrics actually emitted,
or add INP to the upstream pipeline by emitting an inp metric from
runLighthouse/keyMetrics (lighthouse.ts) and ensuring data-extract
validates/passes the inp key through to summary.metrics; update metricLabels
only if you implement the upstream change so metricLabels and summary.metrics
stay consistent.
- Around line 405-413: The zoom limits assignment on
trendsChart.options.plugins.zoom is mutating options but not triggering Chart.js
to reinitialize plugin state; after setting
trendsChart.options.plugins.zoom.limits (the block that sets x.min, x.max,
minRange) call trendsChart.update('none') so the zoom plugin reads the new
limits during its lifecycle hooks; locate the trendsChart object and add a call
to its update method with the 'none' mode immediately after the limits
assignment.
- Around line 493-500: Replace the fragile manual safeUrl logic with the same
URL-parse-and-whitelist used by getSafeHref in template.ts: import getSafeHref,
then compute anchor.href = getSafeHref(summary.url || '') (falling back to
summary.id for textContent only), ensuring getSafeHref validates schemes via the
URL constructor and only allows http/https (and normalizes schemeless //host
paths) so you no longer prepend "https://" blindly; keep anchor.textContent =
summary.url || summary.id and preserve
anchor.target/rel/url.appendChild(anchor).
In `@src/data-extract.ts`:
- Around line 150-158: The current allowlist in the data extraction narrows
rawMetrics to only six keys (expectedMetricKeys) causing serverResponse,
domSize, jsExecTime, and totalByteWeight to be dropped; update the logic in the
block using rawMetrics/expectedMetricKeys/validatedMetrics so that any numeric
metric present in rawMetrics is preserved (either by expanding
expectedMetricKeys to include
['fcp','lcp','tbt','cls','si','tti','serverResponse','domSize','jsExecTime','totalByteWeight']
or, preferably, by iterating over Object.keys(rawMetrics) and copying any value
that is typeof 'number' or null into validatedMetrics) and then assign metrics =
validatedMetrics as before.
In `@src/lighthouse.ts`:
- Around line 131-135: The runLighthouse function currently swallows errors in
its catch block (logging only) so callers like the loop in index-logic.ts and
extractDataFromReports cannot detect failures; change runLighthouse (the
try/catch/finally around the lighthouse run and chrome.kill) to propagate
failure by either re-throwing the caught error or returning a structured failure
result (e.g., { ok: false, error }) instead of returning normally, and update
callers to handle the error/result so iterations, summary.json creation, and
process exit codes can reflect failed audits.
- Around line 102-104: The current fileName uses Date.now() which can collide;
update the filename generation in lighthouse.ts to include a unique per-run
suffix (e.g., the existing iteration variable or a short random runId) so that
fileName and the derived summaryPath cannot collide when two runs share the same
millisecond. Change the line that builds fileName (`const fileName =
`${urlHash}-${mode}-${timestamp}.json``) to append `-iteration` or `-runId` (and
generate runId if needed), and ensure you mirror the exact same stem change when
constructing `summaryPath` so both file and summary use the same unique
identifier.
---
Nitpick comments:
In `@package.json`:
- Line 16: The "clean" npm script uses a POSIX-only command ("rm -f") which
breaks on Windows; replace it with a cross-platform approach by updating the
"clean" script in package.json (the "clean" script entry) to use a portable tool
such as "rimraf lighthouse-runner.mjs lighthouse-runner.mjs.map" and add rimraf
as a devDependency (or alternatively implement a small Node-based script and
point the "clean" script to it), ensuring npm scripts and package.json
dependencies are updated accordingly.
- Around line 8-17: Add a "bin" entry and a "files" whitelist to package.json so
the package installs as a CLI and only ships the built artifacts: add a "bin"
mapping pointing the CLI name (e.g., "lighthouse-runner") to
"lighthouse-runner.mjs" (ensuring build.js still writes the shebang and output
file is executable) and add a "files" array that includes the runtime build
outputs (e.g., "lighthouse-runner.mjs", "lighthouse-runner.mjs.map") plus
relevant docs like "README.md" to avoid publishing source/fixtures/tsconfigs;
keep "prepublishOnly" as-is to ensure build before publish.
- Line 22: package.json declares "type": "commonjs" which contradicts your ESM
outputs (build.js sets format: 'esm' and TS uses import/export); change the
package.json "type" field to "module" to align with emitted ESM artifacts and
avoid silently shipping CJS. After changing to "module", update the build
script: either rename the existing build.js (which uses require()) to build.cjs
or rewrite build.js as ESM (use import instead of require) so the build tooling
remains runnable under the new package "type". Ensure references to build.js in
npm scripts or CI are updated to the new filename or ESM module entry.
In `@src/compression.ts`:
- Around line 88-93: The code always calls fs.writeFileSync(filePath, content)
and recalculates compressedSize even when no image replacements occurred,
causing unnecessary rewrites; change the logic around the content =
content.replace(imgRegex, (m) => replacements.get(m) ?? m) to detect whether any
replacement actually occurred (e.g., compare the new string to the original or
track a flag while replacing) and only call fs.writeFileSync(filePath, content)
and compute compressedSize when content changed; reference the existing
variables imgRegex, replacements, content, filePath, and compressedSize to
implement this gate.
In `@src/dashboard/template.ts`:
- Around line 140-148: The code repeatedly calls calcWeightedAvg six times per
(url, mode) in both modeMetrics and metricsRows causing redundant passes;
introduce a helper (e.g., METRIC_KEYS constant and a weightedMetrics(entries)
function) that maps the six metric keys to their calcWeightedAvg results and
returns an object, then replace the inline repeated calls in modeMetrics
assignment and in the metricsRows branch (where entries is used) with a single
call to weightedMetrics(statsData.urls[url].modes[mode] || []) /
weightedMetrics(entries) so each entries array is scanned once and both call
sites reuse the helper.
In `@src/data-extract.ts`:
- Around line 22-58: averageGroup currently hardcodes iteration: 1 for
multi-entry groups and finds the last entry via reduce; change it to preserve
the original iteration baseline and use a clearer/cheaper last-element lookup:
remove the hardcoded iteration: 1 and instead set iteration to
group[0].iteration (or another intended baseline) while keeping iterationCount:
group.length, and replace lastEntry = group.reduce(...) with lastEntry =
group[group.length - 1] so the function (averageGroup) preserves iteration
semantics and uses the actual last item by position.
In `@src/lighthouse.ts`:
- Around line 52-58: The default Lighthouse log level is too verbose; change the
options object so options.logLevel uses a quieter default (e.g., 'error' or
'warn') instead of 'info', and wire a CLI flag or environment variable (e.g.,
--log-level or LIGHTHOUSE_LOG_LEVEL) into the code that builds the options so
callers can opt into verbose output; update the construction of the options
constant (the options object where logLevel is set) to read the flag/env and
fall back to the quieter default.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9b5ea269-2c59-4467-8efb-56e6ace6e2bf
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
💤 Files with no reviewable changes (1)
- lighthouse-runner.js
✅ Files skipped from review due to trivial changes (7)
- .gitignore
- devenv.nix
- tsconfig.json
- src/dashboard/styles.ts
- justfile
- src/utils.ts
- src/config.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- src/dashboard/index.ts
- .github/workflows/release.yml
- src/index.ts
- src/index-logic.ts
- build.js
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
src/index-logic.ts (1)
35-37: Unconditional sleep after the final audit wastesconfig.delayseconds.The
await sleep(config.delay * 1000)runs after every iteration, including the last URL/mode of the last pass, so every CLI invocation pays an extraconfig.delays before exit. Cheap fix is to skip the wait when there's nothing left to do.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index-logic.ts` around lines 35 - 37, The unconditional await sleep(config.delay * 1000) after console.log(`Waiting ${config.delay}s...`) causes an extra delay after the final audit; change the loop to compute whether this is the last iteration and only call sleep when there are more audits to run. Locate the loop that logs and sleeps (references: console.log, config.delay, sleep) and add an isLastIteration check (e.g., based on loop indices or remaining urls/modes/passes) so that sleep is skipped when true; ensure the console.log still prints only when you will actually wait.src/dashboard/client.ts (1)
33-36:transparentizewill break on uppercase or non‑integer HSL components.
hsl.match(/\d+/g)only captures integer digit runs, so e.g.'hsl(210.5, 100%, 50.2%)'would return['210', '5', '100', '50', '2']and silently produce garbage colors. Today allcolors.*andmodeColors.*are integers, so this is fine — but if anyone later adds a fractional HSL anywhere this fails silently. Either guard with a stricter regex, or just usehsl.replace('hsl(', 'hsla(').replace(')', ', '+alpha+')').🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 33 - 36, transparentize currently parses HSL with hsl.match(/\d+/g) which breaks on uppercase, decimals, signs, or spaces; update the transparentize function to robustly handle HSL strings by either (a) switching to a safer string transform that replaces the leading "hsl(" with "hsla(" and appends ", <alpha>)" to preserve the original content and formatting (handle case-insensitive "hsl" by normalizing to lower/upper as needed), or (b) use a stricter regex that captures optional decimals, optional spaces and the percent sign for the S/L components (e.g., a pattern for numbers like [-+]?\d*\.?\d+ and percent tokens) and then build the hsla string with those captured groups; locate and update the transparentize function to implement one of these fixes so fractional and uppercase HSL values are handled safely.src/lighthouse.ts (1)
105-116: DeadrunDataobject — never read or written.
runDatais built but onlysummary(fromcreateSummaryat line 123) is persisted. Theiterationfield captured inrunDatanever reaches any consumer;src/data-extract.tseven hardcodesiteration: 1because the summary file lacks this key. Either removerunDataentirely, or includeiterationincreateSummary(andsummary.json) so the extractor can recover the original iteration index.♻️ Proposed cleanup
- const runData: RunData = { - id: `${urlHash}-${mode}-${timestamp}`, - url, - urlLabel: getUrlLabel(url), - mode, - iteration, - timestamp, - fileName, - categories, - metrics, - ...(runId != null ? { runId } : {}), - }; - - const reportFilePath = path.join(runsDir, fileName);Also drop the now‑unused
RunDataimport on line 6 (andStatsDataif it remains unused here).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 105 - 116, The constructed runData object (variable runData) is never persisted so iteration is dropped; update the createSummary call (and the produced summary.json) to include the iteration field from runData so downstream extractor can read the real iteration index (ensure createSummary signature and its output JSON schema include iteration), and remove any now-unused RunData and StatsData imports if you end up deleting runData entirely; alternatively, if you prefer removal, delete the runData construction and its import(s) instead of adding iteration to the summary.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/dashboard/client.ts`:
- Around line 184-186: The chart currently coerces null metric values to 0 by
directly dividing/mapping (e.g., the FCP/LCP/TBT dataset entries), so replace
those mappings to preserve nulls instead of turning them into zeroes; for each
dataset (labels 'FCP (s)', 'LCP (s)', 'TBT (ms)') change the data mapping to
return null when the source value is null/undefined (e.g., use a conditional
like d.fcp == null ? null : d.fcp / 1000 and similarly for d.lcp and d.tbt) so
the chart renders gaps rather than plotting a bogus 0 value.
- Around line 213-215: getRawScores currently maps r.categories.performance
(typed number | null) directly into the boxplot data, causing nulls to be
treated as 0 and skewing whiskers; update getRawScores to filter out non-numeric
values before returning (e.g., for each inner array map/filter to keep only
typeof value === "number" or Number.isFinite), referencing getRawScores and the
r.categories.performance/IterationEntry types so the boxplot receives only
actual numeric scores.
In `@src/dashboard/template.ts`:
- Around line 170-178: The summary.id fallback is dead because the array
elements in statsData.urls[url].modes[mode] are IterationEntry (which lack id)
so r.id is always undefined; update the construction in summary to use
r.fileName directly (replace r.id || r.fileName with r.fileName) and remove the
unused r.id lookup so summary: { id: r.fileName, url, urlLabel, mode, timestamp:
r.timestamp, categories: r.categories, metrics: r.metrics } is used consistently
when building entries for IterationEntry.
In `@src/data-extract.ts`:
- Around line 165-173: The iteration field is being hardcoded to 1 in the
extractor; update the source summary and the extractor: modify createSummary (in
src/lighthouse.ts) to include the iteration value produced by runLighthouse so
the summary JSON contains summary.iteration, then change the code that builds
RunData/IterationEntry (where iteration is currently set to 1) to read iteration
from summary.iteration (use conditional fallback if absent). Update references
to RunData.iteration and IterationEntry.iteration to use summary.iteration
instead of the literal 1 so downstream consumers see the real iteration index.
In `@src/index-logic.ts`:
- Around line 24-40: The per-URL catch is swallowing errors from runLighthouse
and losing root causes while the process still exits 0; update the loop that
calls runLighthouse (inside the iterations/config.urls/config.emulations nested
loops) to: 1) log the actual error (use err.message or err.toString()) when a
run fails instead of discarding the error, 2) increment a local failure counter
(e.g., failedRuns) so failures are tracked, and 3) after the iterations complete
check failedRuns and if >0 call process.exit(1) (or throw) so CI observes a
non-zero exit; preserve the existing delay/sleep behavior and continue to await
sleep(config.delay * 1000) after logging the error.
---
Nitpick comments:
In `@src/dashboard/client.ts`:
- Around line 33-36: transparentize currently parses HSL with hsl.match(/\d+/g)
which breaks on uppercase, decimals, signs, or spaces; update the transparentize
function to robustly handle HSL strings by either (a) switching to a safer
string transform that replaces the leading "hsl(" with "hsla(" and appends ",
<alpha>)" to preserve the original content and formatting (handle
case-insensitive "hsl" by normalizing to lower/upper as needed), or (b) use a
stricter regex that captures optional decimals, optional spaces and the percent
sign for the S/L components (e.g., a pattern for numbers like [-+]?\d*\.?\d+ and
percent tokens) and then build the hsla string with those captured groups;
locate and update the transparentize function to implement one of these fixes so
fractional and uppercase HSL values are handled safely.
In `@src/index-logic.ts`:
- Around line 35-37: The unconditional await sleep(config.delay * 1000) after
console.log(`Waiting ${config.delay}s...`) causes an extra delay after the final
audit; change the loop to compute whether this is the last iteration and only
call sleep when there are more audits to run. Locate the loop that logs and
sleeps (references: console.log, config.delay, sleep) and add an isLastIteration
check (e.g., based on loop indices or remaining urls/modes/passes) so that sleep
is skipped when true; ensure the console.log still prints only when you will
actually wait.
In `@src/lighthouse.ts`:
- Around line 105-116: The constructed runData object (variable runData) is
never persisted so iteration is dropped; update the createSummary call (and the
produced summary.json) to include the iteration field from runData so downstream
extractor can read the real iteration index (ensure createSummary signature and
its output JSON schema include iteration), and remove any now-unused RunData and
StatsData imports if you end up deleting runData entirely; alternatively, if you
prefer removal, delete the runData construction and its import(s) instead of
adding iteration to the summary.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5bcb44b8-8815-4440-8567-45b1ab72ecbf
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
💤 Files with no reviewable changes (1)
- lighthouse-runner.js
✅ Files skipped from review due to trivial changes (7)
- devenv.nix
- .gitignore
- tsconfig.json
- src/dashboard/styles.ts
- .github/workflows/release.yml
- src/utils.ts
- src/config.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- src/index.ts
- build.js
- justfile
- package.json
| summary: { | ||
| id: r.id || r.fileName, | ||
| url: url, | ||
| urlLabel: urlLabel, | ||
| mode: mode, | ||
| timestamp: r.timestamp, | ||
| categories: r.categories, | ||
| metrics: r.metrics | ||
| }, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm IterationEntry shape vs RunData shape.
rg -nP --type=ts -C2 '\binterface\s+(IterationEntry|RunData)\b'Repository: freke/light-house-report
Length of output: 420
🏁 Script executed:
cat -n src/utils.ts | head -60Repository: freke/light-house-report
Length of output: 1678
🏁 Script executed:
sed -n '160,180p' src/dashboard/template.tsRepository: freke/light-house-report
Length of output: 741
r.id || r.fileName always evaluates to r.fileName — simplify by removing the dead fallback.
statsData.urls[url].modes[mode] contains IterationEntry elements, which do not have an id field (only RunData has id). The r.id access is always undefined, so the || operator unconditionally falls through to r.fileName. Simplify the code by removing the dead lookup.
- id: r.id || r.fileName,
+ id: r.fileName,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| summary: { | |
| id: r.id || r.fileName, | |
| url: url, | |
| urlLabel: urlLabel, | |
| mode: mode, | |
| timestamp: r.timestamp, | |
| categories: r.categories, | |
| metrics: r.metrics | |
| }, | |
| summary: { | |
| id: r.fileName, | |
| url: url, | |
| urlLabel: urlLabel, | |
| mode: mode, | |
| timestamp: r.timestamp, | |
| categories: r.categories, | |
| metrics: r.metrics | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/dashboard/template.ts` around lines 170 - 178, The summary.id fallback is
dead because the array elements in statsData.urls[url].modes[mode] are
IterationEntry (which lack id) so r.id is always undefined; update the
construction in summary to use r.fileName directly (replace r.id || r.fileName
with r.fileName) and remove the unused r.id lookup so summary: { id: r.fileName,
url, urlLabel, mode, timestamp: r.timestamp, categories: r.categories, metrics:
r.metrics } is used consistently when building entries for IterationEntry.
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (11)
src/lighthouse.ts (2)
7-7: Unusedargsimport.
argsis imported from./config.jsbut never referenced in this module. Drop it to keep imports tidy.♻️ Proposed fix
-import { config, runsDir, args } from './config.js'; +import { config, runsDir } from './config.js'; </details> <details> <summary>🤖 Prompt for AI Agents</summary>Verify each finding against the current code and only fix it if needed.
In
@src/lighthouse.tsat line 7, The import list in src/lighthouse.ts includes
an unused symbol args; remove args from the import so only config and runsDir
are imported (update the import statement that currently names config, runsDir,
args). Verify there are no remaining references to args in the file and run a
quick linter/test to ensure the unused-import warning is resolved.</details> --- `13-25`: **Prefer `structuredClone` over `JSON.parse(JSON.stringify(...))`.** `JSON.parse(JSON.stringify(lhr))` is the slowest deep-clone option and silently drops `undefined`, functions, and non-enumerable properties. Lighthouse reports can be several MB, and this is the hot path before each report write. `structuredClone` (built into Node 17+, well within your `engines.node >= 25` target) is faster and preserves more types. <details> <summary>♻️ Proposed change</summary> ```diff - const cleaned = JSON.parse(JSON.stringify(lhr)); + const cleaned = structuredClone(lhr);Additionally,
stripScreenshotsonly removes audits whosedetails.type === 'screenshot'. Lighthouse also emits screenshot-bearing details likefilmstripandscreenshot-thumbnails; if your goal is to keep report files small, consider extending the filter. Optional.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 13 - 25, Replace the slow JSON-based deep clone in stripScreenshots with structuredClone: in function stripScreenshots(lhr) use structuredClone(lhr) instead of JSON.parse(JSON.stringify(lhr)) to preserve types and speed up cloning; keep the rest of the logic but also extend the audit-filtering condition in the loop (the checked symbol is stripScreenshots and the audits iteration over cleaned.audits) to drop audits where a.details.type is 'screenshot' or other screenshot-bearing types like 'filmstrip' and 'screenshot-thumbnails' so the resulting cleaned object omits all screenshot-related details.src/dashboard/template.ts (2)
4-4: Unusedconfigimport.
configis imported from'../config.js'but never referenced in this module.♻️ Proposed fix
-import { StatsData, IterationEntry } from '../utils.js'; -import { config } from '../config.js'; +import { StatsData, IterationEntry } from '../utils.js';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` at line 4, The import of config from '../config.js' in src/dashboard/template.ts is unused; remove the unused import statement (the "config" import) from the top of the module or, if configuration values are actually required, update the template module to reference the config symbols instead; ensure no other references to "config" remain before committing.
91-96:getGradeparameter widened by callers may receiveundefined.The signature is
(val: number | null, ...)but callers likegetGrade(metrics.fcp, ...)pass values frommetrics: any, which in practice can beundefinedfor missing keys.val == nullalready covers both, but tightening the type tonumber | null | undefineddocuments the contract and helps ifmetricsis later strictly typed.♻️ Proposed change
- const getGrade = (val: number | null, thresholds: number[]) => { + const getGrade = (val: number | null | undefined, thresholds: number[]) => { if (val == null) return 'none';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 91 - 96, The getGrade function's first parameter can be undefined when callers pass dynamic metrics (e.g., getGrade(metrics.fcp, ...)), so update its signature from (val: number | null, thresholds: number[]) to (val: number | null | undefined, thresholds: number[]) and keep the existing null/undefined guard (if (val == null) return 'none'); also search for other usages of getGrade and update any type annotations or overloads to accept undefined so callers with possibly-missing metric keys compile cleanly.build.js (2)
11-11:target: 'node24'is inconsistent withpackage.jsonengines.node: ">=25.0.0".esbuild is told to compile for Node 24, but
package.jsondeclares the minimum supported Node version as>=25.0.0. Either align by bumping the esbuild target tonode25, or relaxengines.nodeback to>=24.0.0. Today this means esbuild will conservatively transpile/avoid Node-25-only features even when only Node 25 is supported, which is harmless but misleading.♻️ Proposed change
- target: 'node24', + target: 'node25',🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` at line 11, The esbuild config sets target: 'node24' while package.json declares engines.node: ">=25.0.0", causing an inconsistency; update the esbuild target to 'node25' to match package.json (or alternatively relax engines.node to ">=24.0.0") so the build target and declared engine align—locate the target: 'node24' entry in the build.js esbuild config and change it to 'node25', or adjust package.json's engines.node accordingly.
41-44: Dead branch: esbuild rejects on errors by default.
esbuild.build(buildOptions)throws on errors with the defaultlogLevel, soresult.errors.length > 0essentially never triggers — control will land in the outercatch. This is harmless, but if you want a single error-reporting path you can drop theresult.errorscheck, or setlogLevel: 'silent'and rely onresult.errorsexclusively.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` around lines 41 - 44, esbuild.build(buildOptions) throws on build errors, so the if (result.errors.length > 0) branch is dead; remove that conditional and rely on the outer catch to handle failures (i.e., keep the await esbuild.build(buildOptions) call and process.exit(1) in the catch), or alternatively set buildOptions.logLevel = 'silent' and explicitly check result.errors after esbuild.build(buildOptions) if you prefer a single-path error-reporting via the result object.src/compression.ts (2)
5-5: Unusedargsimport.
argsis imported from./config.jsbut never referenced in this module. Consider removing it to keep imports clean.♻️ Proposed fix
-import { runsDir, args } from './config.js'; +import { runsDir } from './config.js';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` at line 5, The import statement currently brings in both runsDir and args from config (symbol: args) but args is not used in this module; remove args from the import so the line only imports runsDir (i.e., change the import that references runsDir, args to import only runsDir) and run a quick search for any later references to args in functions in this file (e.g., any uses inside compression-related helpers) to ensure you don't accidentally remove needed logic.
90-93: ReusingimgRegexfor both.exec()and.replace()is correct, but consider readability.The same global regex is iterated in the
whileloop (line 47) and then re-used incontent.replace(imgRegex, ...)(line 91). Per spec this is safe —String.prototype.replaceresetslastIndexinternally — but it can mislead readers. A small clarification: sincereplacementsis keyed byfullMatch, you could replace by direct string concatenation in the loop or use a freshly cloned regex to make intent explicit. Optional cleanup.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` around lines 90 - 93, The code reuses the global imgRegex for both the while loop (using RegExp.prototype.exec) and later in content.replace(imgRegex, ...) which is valid but unclear; change to explicitly avoid reusing the same RegExp by either (a) performing the replacement inside the existing loop using the replacements Map and string concatenation (use the same fullMatch keys populated into replacements) and write filePath once, or (b) create a fresh RegExp instance (e.g., new RegExp(imgRegex.source, imgRegex.flags)) and pass that into content.replace so the replace call uses a reset regex; update code around imgRegex, replacements, content, and filePath accordingly.tsconfig.json (1)
3-16:lib: ["ES2020"]is belowtarget: "ES2022".With
targetset toES2022butlibpinned toES2020, ES2022 globals such asArray.prototype.at,Object.hasOwn,Error.cause, andObject.hasOwnwon't be in the type-check library, causing legitimate ES2022 code to fail typechecking. Bumplibto match the target (or drop the explicitlibentry to fall back to the target's default libs).♻️ Proposed change
- "lib": ["ES2020"], + "lib": ["ES2022"],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tsconfig.json` around lines 3 - 16, The tsconfig currently sets "target": "ES2022" but pins "lib": ["ES2020"], which omits ES2022 runtime typings; update the tsconfig so the "lib" entry matches the target (e.g., change "lib" to ["ES2022"] or remove the "lib" key entirely to let the compiler pick defaults) — edit the tsconfig JSON to either replace the "lib" array with ["ES2022"] or delete the "lib" property, ensuring "target": "ES2022" and "module": "NodeNext" remain unchanged.src/data-extract.ts (1)
13-15: PreferNumber.isNaNover globalisNaN.Global
isNaNperforms type coercion (isNaN("foo") === true) and is footgun-prone. Since these arrays are typed(number | null)[],Number.isNaNis both safer and idiomatic.♻️ Suggested change
- const valid = values.filter((v): v is number => v != null && !isNaN(v)); + const valid = values.filter((v): v is number => v != null && !Number.isNaN(v));(Same for line 37's filter.)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/data-extract.ts` around lines 13 - 15, Update the type-guarding filters to use Number.isNaN instead of the global isNaN: change expressions like values.filter((v): v is number => v != null && !isNaN(v)) to use !Number.isNaN(v) so the filter on variable valid (and the similar filter around line 37) uses Number.isNaN for accurate, non-coercing NaN checks while preserving the null check and type guard.src/dashboard/client.ts (1)
296-322: Consider try/finally for the sync-guard flags so an exception can't permanently lock zoom↔slider sync.
isUpdatingSlider/isUpdatingChartare reset on the last line of each handler. IftimelineSlider.set,trendsChart.zoomScale, orupdateVisibleLabelsever throws, the flag staystruefor the rest of the page's lifetime and the corresponding direction of sync silently stops working (no error visible to the user — the chart and slider just drift apart). The same pattern applies to thetimelineSlider.on('update', …)callback at lines 415‑423. Wrapping the bodies intry { … } finally { flag = false; }makes the recovery deterministic.♻️ Example for `onZoom` (apply analogously to `onPan` and the slider `update` handler)
onZoom: ({ chart }) => { if (isUpdatingChart || isUpdatingSlider) return; isUpdatingSlider = true; - const min = chart.scales.x.min; - const max = chart.scales.x.max; - if (timelineSlider) { - timelineSlider.set([min, max], true, true); - } - updateVisibleLabels(min, max); - isUpdatingSlider = false; + try { + const min = chart.scales.x.min; + const max = chart.scales.x.max; + if (timelineSlider) { + timelineSlider.set([min, max], true, true); + } + updateVisibleLabels(min, max); + } finally { + isUpdatingSlider = false; + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/client.ts` around lines 296 - 322, The sync-guard flags (isUpdatingSlider / isUpdatingChart) in the chart handlers can be left true if an exception is thrown; wrap the body of the onZoom and onPan handlers (and the timelineSlider.on('update') callback) in try { ... } finally { isUpdatingSlider = false; } (or isUpdatingChart as appropriate) so the flag is always reset even if timelineSlider.set, trendsChart.zoomScale, updateVisibleLabels or other calls throw; keep the early-return guard (if (isUpdatingChart || isUpdatingSlider) return) and set the flag to true before the try block to preserve the intended mutual-exclusion semantics.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 13-17: package.json lists "lighthouse-runner.mjs.map" in the
"files" array (and the clean script) but build.js currently sets sourcemap:
false so that .map is never emitted; either enable sourcemaps in build.js (set
the bundler/rollup/esbuild option sourcemap: true where build.js creates the
bundle) so lighthouse-runner.mjs.map is produced, or remove
"lighthouse-runner.mjs.map" from package.json's "files" array and from the clean
script to avoid referencing a non-existent file—pick one fix and update the
corresponding place (package.json "files"/clean and/or the sourcemap setting in
build.js).
- Around line 5-7: The package.json "engines.node" setting is overly strict
(">=25.0.0") and should be relaxed to match the actual build target and Node
LTS; update the engines.node field in package.json (the "engines" object /
"node" property) to ">=22.0.0" or ">=22.12.0" so it supports Node 22 LTS and
aligns with the node24 build target, without changing runtime code paths or
browser-only localStorage usage.
In `@src/dashboard/client.ts`:
- Around line 131-134: getAggAvg currently treats null category scores as zero
via r.categories[key] || 0, which biases the average; update getAggAvg so it
only includes numeric (non-null/undefined) category values when building vals
(e.g., use data.flatMap(d => d.map(r => r.categories[key]).filter(v => v !=
null)) or equivalent) and then compute the average over those filtered values,
returning 0 only if there are no valid numbers; reference getAggAvg and
r.categories[key] and mirror the null-handling used in
getRawScores/createMetricsChart.
- Around line 508-512: The categories.innerHTML rendering uses catLabels[key]
which can be undefined for unknown category keys; update the mapping to either
filter out entries without catLabels (e.g.,
Object.entries(summary.categories).filter(([k]) => catLabels[k])) or use a safe
fallback for the label (e.g., const label = catLabels[key] ?? key) before
building the card; keep the existing score/grade logic and assign the joined
HTML back to categories.innerHTML.
In `@src/data-extract.ts`:
- Line 168: The assignment using summary.timestamp || Date.now() can inject
current time for missing or zero timestamps and corrupt trends; update the
processing in the block that sets timestamp (the assignment of timestamp:
summary.timestamp || Date.now()) to instead validate that summary.timestamp is a
finite positive number, and if not either log a warning (including identifying
info from the record) and skip the entry (continue) or otherwise exclude it from
time-series outputs; ensure you use Number.isFinite(summary.timestamp) and treat
0/null/undefined as invalid rather than falling back to Date.now().
In `@tsconfig.json`:
- Line 19: The tsconfig.json "include" array references "build.ts" which doesn't
exist (the repo has build.js); update the tsconfig.json include so it no longer
references "build.ts" (or alternatively rename the build script to build.ts) to
ensure tsc includes real files—modify the "include" entry that mentions build.ts
accordingly so TypeScript doesn't silently include a non-existent file.
---
Nitpick comments:
In `@build.js`:
- Line 11: The esbuild config sets target: 'node24' while package.json declares
engines.node: ">=25.0.0", causing an inconsistency; update the esbuild target to
'node25' to match package.json (or alternatively relax engines.node to
">=24.0.0") so the build target and declared engine align—locate the target:
'node24' entry in the build.js esbuild config and change it to 'node25', or
adjust package.json's engines.node accordingly.
- Around line 41-44: esbuild.build(buildOptions) throws on build errors, so the
if (result.errors.length > 0) branch is dead; remove that conditional and rely
on the outer catch to handle failures (i.e., keep the await
esbuild.build(buildOptions) call and process.exit(1) in the catch), or
alternatively set buildOptions.logLevel = 'silent' and explicitly check
result.errors after esbuild.build(buildOptions) if you prefer a single-path
error-reporting via the result object.
In `@src/compression.ts`:
- Line 5: The import statement currently brings in both runsDir and args from
config (symbol: args) but args is not used in this module; remove args from the
import so the line only imports runsDir (i.e., change the import that references
runsDir, args to import only runsDir) and run a quick search for any later
references to args in functions in this file (e.g., any uses inside
compression-related helpers) to ensure you don't accidentally remove needed
logic.
- Around line 90-93: The code reuses the global imgRegex for both the while loop
(using RegExp.prototype.exec) and later in content.replace(imgRegex, ...) which
is valid but unclear; change to explicitly avoid reusing the same RegExp by
either (a) performing the replacement inside the existing loop using the
replacements Map and string concatenation (use the same fullMatch keys populated
into replacements) and write filePath once, or (b) create a fresh RegExp
instance (e.g., new RegExp(imgRegex.source, imgRegex.flags)) and pass that into
content.replace so the replace call uses a reset regex; update code around
imgRegex, replacements, content, and filePath accordingly.
In `@src/dashboard/client.ts`:
- Around line 296-322: The sync-guard flags (isUpdatingSlider / isUpdatingChart)
in the chart handlers can be left true if an exception is thrown; wrap the body
of the onZoom and onPan handlers (and the timelineSlider.on('update') callback)
in try { ... } finally { isUpdatingSlider = false; } (or isUpdatingChart as
appropriate) so the flag is always reset even if timelineSlider.set,
trendsChart.zoomScale, updateVisibleLabels or other calls throw; keep the
early-return guard (if (isUpdatingChart || isUpdatingSlider) return) and set the
flag to true before the try block to preserve the intended mutual-exclusion
semantics.
In `@src/dashboard/template.ts`:
- Line 4: The import of config from '../config.js' in src/dashboard/template.ts
is unused; remove the unused import statement (the "config" import) from the top
of the module or, if configuration values are actually required, update the
template module to reference the config symbols instead; ensure no other
references to "config" remain before committing.
- Around line 91-96: The getGrade function's first parameter can be undefined
when callers pass dynamic metrics (e.g., getGrade(metrics.fcp, ...)), so update
its signature from (val: number | null, thresholds: number[]) to (val: number |
null | undefined, thresholds: number[]) and keep the existing null/undefined
guard (if (val == null) return 'none'); also search for other usages of getGrade
and update any type annotations or overloads to accept undefined so callers with
possibly-missing metric keys compile cleanly.
In `@src/data-extract.ts`:
- Around line 13-15: Update the type-guarding filters to use Number.isNaN
instead of the global isNaN: change expressions like values.filter((v): v is
number => v != null && !isNaN(v)) to use !Number.isNaN(v) so the filter on
variable valid (and the similar filter around line 37) uses Number.isNaN for
accurate, non-coercing NaN checks while preserving the null check and type
guard.
In `@src/lighthouse.ts`:
- Line 7: The import list in src/lighthouse.ts includes an unused symbol args;
remove args from the import so only config and runsDir are imported (update the
import statement that currently names config, runsDir, args). Verify there are
no remaining references to args in the file and run a quick linter/test to
ensure the unused-import warning is resolved.
- Around line 13-25: Replace the slow JSON-based deep clone in stripScreenshots
with structuredClone: in function stripScreenshots(lhr) use structuredClone(lhr)
instead of JSON.parse(JSON.stringify(lhr)) to preserve types and speed up
cloning; keep the rest of the logic but also extend the audit-filtering
condition in the loop (the checked symbol is stripScreenshots and the audits
iteration over cleaned.audits) to drop audits where a.details.type is
'screenshot' or other screenshot-bearing types like 'filmstrip' and
'screenshot-thumbnails' so the resulting cleaned object omits all
screenshot-related details.
In `@tsconfig.json`:
- Around line 3-16: The tsconfig currently sets "target": "ES2022" but pins
"lib": ["ES2020"], which omits ES2022 runtime typings; update the tsconfig so
the "lib" entry matches the target (e.g., change "lib" to ["ES2022"] or remove
the "lib" key entirely to let the compiler pick defaults) — edit the tsconfig
JSON to either replace the "lib" array with ["ES2022"] or delete the "lib"
property, ensuring "target": "ES2022" and "module": "NodeNext" remain unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: f7526e60-e189-475b-8a8c-e2a55a89ffd2
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
💤 Files with no reviewable changes (1)
- lighthouse-runner.js
✅ Files skipped from review due to trivial changes (4)
- devenv.nix
- .gitignore
- src/dashboard/styles.ts
- src/config.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- .github/workflows/release.yml
- justfile
- src/index.ts
- src/index-logic.ts
- src/utils.ts
| "lib": ["ES2020"], | ||
| "types": ["node"] | ||
| }, | ||
| "include": ["src/**/*", "build.ts"], |
There was a problem hiding this comment.
include references build.ts but the build script is build.js.
The repository ships build.js (JavaScript), not build.ts. As written, tsc --noEmit will silently include nothing for this entry. Either rename the build script to build.ts and let TypeScript compile it, or remove build.ts from include.
♻️ Proposed change (option A: drop the entry)
- "include": ["src/**/*", "build.ts"],
+ "include": ["src/**/*"],📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| "include": ["src/**/*", "build.ts"], | |
| "include": ["src/**/*"], |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tsconfig.json` at line 19, The tsconfig.json "include" array references
"build.ts" which doesn't exist (the repo has build.js); update the tsconfig.json
include so it no longer references "build.ts" (or alternatively rename the build
script to build.ts) to ensure tsc includes real files—modify the "include" entry
that mentions build.ts accordingly so TypeScript doesn't silently include a
non-existent file.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
src/lighthouse.ts (1)
13-25: Optional: avoid cloning screenshots before discarding them.
JSON.parse(JSON.stringify(lhr))deep-clones the entire report — includingfullPageScreenshotand any base64 screenshot audits — only to delete them right after. For the typical Lighthouse report this is the heaviest part of the payload. SincerunnerResult.lhris not reused after this call, you can either delete in place, or usestructuredClone(Node ≥17) and then prune. Not blocking; just avoids a meaningful per-run allocation.♻️ Proposed refactor
-function stripScreenshots(lhr: any): any { - const cleaned = JSON.parse(JSON.stringify(lhr)); - delete cleaned.fullPageScreenshot; - if (cleaned.audits) { - for (const [key, audit] of Object.entries(cleaned.audits)) { - const a = audit as any; - if (a.details && a.details.type === 'screenshot') { - delete cleaned.audits[key]; - } - } - } - return cleaned; -} +function stripScreenshots(lhr: any): any { + // Mutates the input — caller does not reuse runnerResult.lhr after this. + delete lhr.fullPageScreenshot; + if (lhr.audits) { + for (const key of Object.keys(lhr.audits)) { + const a = lhr.audits[key]; + if (a?.details?.type === 'screenshot') { + delete lhr.audits[key]; + } + } + } + return lhr; +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 13 - 25, The stripScreenshots function currently does a heavy JSON.parse(JSON.stringify(lhr)) clone; instead mutate the passed-in object (or use structuredClone if you require immutability on Node ≥17) and remove screenshot data in place: delete lhr.fullPageScreenshot and iterate lhr.audits (use Object.entries on lhr.audits) deleting any audit where audit.details?.type === 'screenshot' (reference function name stripScreenshots and variable cleaned/audit entries) so you avoid cloning large base64 payloads before discarding them.src/config.ts (2)
67-81: Invalid CLI flag values are silently dropped.
--run abc,--quality 200, or--quality 0are accepted by the parser and then discarded — the user gets the default with no indication their intent was ignored. For a CI-friendly CLI this is easy to mis-debug. Consider warning on stderr (or exiting non-zero) when a flag is present but its value fails validation.♻️ Proposed refactor
if (arg === '--run' && arr[i + 1] !== undefined) { const val = parseInt(arr[i + 1], 10); - if (!isNaN(val)) acc.runIterations = val; + if (!isNaN(val) && val >= 0) acc.runIterations = val; + else console.warn(`⚠️ Ignoring --run "${arr[i + 1]}" (must be a non-negative integer)`); } if (arg === '--compress') acc.compress = true; if (arg === '--quality' && arr[i + 1] !== undefined) { const val = parseInt(arr[i + 1], 10); if (!isNaN(val) && val >= 1 && val <= 100) acc.quality = val; + else console.warn(`⚠️ Ignoring --quality "${arr[i + 1]}" (must be 1-100)`); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 67 - 81, The CLI parser in args (reduce over process.argv.slice(2)) silently ignores invalid values for --run and --quality; update the logic in the args reducer to detect when a flag is present but its value is invalid (e.g., non-numeric for --run, non-numeric or outside 1..100 for --quality) and handle it by writing a clear error to stderr (console.error) and exiting with a non-zero code (process.exit(1)) or returning an explicit parse error; keep the existing assignments for valid values (runIterations, quality, compress) and ensure the check references the same symbols (args, runIterations, quality, compress) so users are notified when provided values are rejected.
53-56: Unrecognised emulation strings are silently accepted.The
as anycast letsLHR_EMULATIONS=mobile-3g,foo(or a typo inlighthouse-runner.config.json) flow through unchanged. Downstream,src/lighthouse.tsonly matches the three documented strings, so an unknown value falls into the "non-desktop, non-throttled mobile" branch — i.e., it runs but with throttling disabled, and the dashboard later groups results under that arbitrary mode label. Worth validating against the union and warning/throwing on unknown entries.♻️ Proposed refactor
- emulations: (process.env.LHR_EMULATIONS - ? process.env.LHR_EMULATIONS.split(',').map(s => s.trim()) - : fileConfig.emulations || ['mobile-4g', 'mobile-wifi', 'desktop']) as any, + emulations: (() => { + const valid = new Set(['mobile-4g', 'mobile-wifi', 'desktop'] as const); + const raw = process.env.LHR_EMULATIONS + ? process.env.LHR_EMULATIONS.split(',').map(s => s.trim()) + : fileConfig.emulations || ['mobile-4g', 'mobile-wifi', 'desktop']; + const unknown = raw.filter((m: string) => !valid.has(m as any)); + if (unknown.length) { + throw new Error(`❌ Unknown emulation(s): ${unknown.join(', ')}. Allowed: ${[...valid].join(', ')}`); + } + return raw as Config['emulations']; + })(),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/config.ts` around lines 53 - 56, The emulations value currently uses an unchecked cast (emulations) allowing unknown strings; change construction to validate entries from LHR_EMULATIONS/fileConfig.emulations against the allowed union ['mobile-4g','mobile-wifi','desktop'] (use a const ALLOWED_EMULATIONS set or array) and replace the "as any" cast with a properly typed string[]; for any unknown entries either log a warning via the existing logger and drop them (preferred) or throw an error, and ensure the final emulations value passed to the rest of the app (referenced by the emulations variable and downstream matching in src/lighthouse.ts) contains only the allowed tokens so unknown labels cannot silently propagate.src/dashboard/template.ts (1)
230-241: Optional: collapse the nine.replace()calls into a single pass.Each
.replace()re-scans the full bundled client. A single regex over allINJECT_*tokens with a replacer dispatch would avoid 9 sequential O(n) scans ofclientJsand remove the easy-to-forget pattern of adding a new key in two places.♻️ Proposed refactor
- const safeInject = (data: any) => JSON.stringify(data).replace(/<\/script>/g, '<\\/script>'); - - const clientInjected = clientJs - .replace(/\bINJECT_urlLabels\b/g, () => safeInject(urlLabels)) - .replace(/\bINJECT_allModes\b/g, () => safeInject(allModes)) - .replace(/\bINJECT_modePerf\b/g, () => safeInject(modePerf)) - .replace(/\bINJECT_modeMetrics\b/g, () => safeInject(modeMetrics)) - .replace(/\bINJECT_trendData\b/g, () => safeInject(trendData)) - .replace(/\bINJECT_allRunsData\b/g, () => safeInject(allRunsData)) - .replace(/\bINJECT_categoryKeys\b/g, () => safeInject(categoryKeys)) - .replace(/\bINJECT_modeDataAvg\b/g, () => safeInject(modeDataAvg)) - .replace(/\bINJECT_modeDataRaw\b/g, () => safeInject(modeDataRaw)); + const safeInject = (data: unknown) => + JSON.stringify(data).replace(/<\/script>/g, '<\\/script>'); + + const injections: Record<string, unknown> = { + INJECT_urlLabels: urlLabels, + INJECT_allModes: allModes, + INJECT_modePerf: modePerf, + INJECT_modeMetrics: modeMetrics, + INJECT_trendData: trendData, + INJECT_allRunsData: allRunsData, + INJECT_categoryKeys: categoryKeys, + INJECT_modeDataAvg: modeDataAvg, + INJECT_modeDataRaw: modeDataRaw, + }; + const clientInjected = clientJs.replace( + /\bINJECT_(?:urlLabels|allModes|modePerf|modeMetrics|trendData|allRunsData|categoryKeys|modeDataAvg|modeDataRaw)\b/g, + (match) => safeInject(injections[match]), + );🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 230 - 241, Replace the nine sequential .replace() calls on clientJs with a single .replace using a regex like /\bINJECT_([A-Za-z0-9_]+)\b/g and a replacer function that looks up the matched key in a mapping object (map keys like "urlLabels", "allModes", "modePerf", "modeMetrics", "trendData", "allRunsData", "categoryKeys", "modeDataAvg", "modeDataRaw") and returns safeInject(mappedValue); update the clientInjected assignment to use this single-pass replace, reference the existing safeInject function for escaping, and ensure the replacer returns the original match or throws a clear error if an unknown INJECT_* token is encountered so missing keys are detected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 5-7: The package.json "engines.node" floor (engines.node) does not
match the esbuild target in build.js ("target: 'node24'"); update one to match:
either bump engines.node to ">=24.0.0" or change the esbuild config's target in
build.js from 'node24' to 'node22' so the declared engine and build target are
consistent (adjust any related CI/build docs if present).
---
Nitpick comments:
In `@src/config.ts`:
- Around line 67-81: The CLI parser in args (reduce over process.argv.slice(2))
silently ignores invalid values for --run and --quality; update the logic in the
args reducer to detect when a flag is present but its value is invalid (e.g.,
non-numeric for --run, non-numeric or outside 1..100 for --quality) and handle
it by writing a clear error to stderr (console.error) and exiting with a
non-zero code (process.exit(1)) or returning an explicit parse error; keep the
existing assignments for valid values (runIterations, quality, compress) and
ensure the check references the same symbols (args, runIterations, quality,
compress) so users are notified when provided values are rejected.
- Around line 53-56: The emulations value currently uses an unchecked cast
(emulations) allowing unknown strings; change construction to validate entries
from LHR_EMULATIONS/fileConfig.emulations against the allowed union
['mobile-4g','mobile-wifi','desktop'] (use a const ALLOWED_EMULATIONS set or
array) and replace the "as any" cast with a properly typed string[]; for any
unknown entries either log a warning via the existing logger and drop them
(preferred) or throw an error, and ensure the final emulations value passed to
the rest of the app (referenced by the emulations variable and downstream
matching in src/lighthouse.ts) contains only the allowed tokens so unknown
labels cannot silently propagate.
In `@src/dashboard/template.ts`:
- Around line 230-241: Replace the nine sequential .replace() calls on clientJs
with a single .replace using a regex like /\bINJECT_([A-Za-z0-9_]+)\b/g and a
replacer function that looks up the matched key in a mapping object (map keys
like "urlLabels", "allModes", "modePerf", "modeMetrics", "trendData",
"allRunsData", "categoryKeys", "modeDataAvg", "modeDataRaw") and returns
safeInject(mappedValue); update the clientInjected assignment to use this
single-pass replace, reference the existing safeInject function for escaping,
and ensure the replacer returns the original match or throws a clear error if an
unknown INJECT_* token is encountered so missing keys are detected.
In `@src/lighthouse.ts`:
- Around line 13-25: The stripScreenshots function currently does a heavy
JSON.parse(JSON.stringify(lhr)) clone; instead mutate the passed-in object (or
use structuredClone if you require immutability on Node ≥17) and remove
screenshot data in place: delete lhr.fullPageScreenshot and iterate lhr.audits
(use Object.entries on lhr.audits) deleting any audit where audit.details?.type
=== 'screenshot' (reference function name stripScreenshots and variable
cleaned/audit entries) so you avoid cloning large base64 payloads before
discarding them.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 365819e3-43ad-4de1-a1c9-4416e07f10ea
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
💤 Files with no reviewable changes (1)
- lighthouse-runner.js
✅ Files skipped from review due to trivial changes (9)
- devenv.nix
- .gitignore
- src/index.ts
- .github/workflows/release.yml
- tsconfig.json
- src/dashboard/styles.ts
- justfile
- src/utils.ts
- src/dashboard/client.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- src/dashboard/index.ts
- src/index-logic.ts
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (1)
src/index-logic.ts (1)
48-52:⚠️ Potential issue | 🟡 Minor
process.exit(1)skips dashboard regeneration on partial failure.If even one URL/mode tuple fails, this branch hard-exits before
extractDataFromReports()andgenerateVisualReport(statsData)run on lines 54–56 — so all successful iterations from this run are written to disk but the dashboard never picks them up. CI gets the non-zero exit code as intended, but operators lose visibility into the results that did succeed.Use
process.exitCode = 1instead so the rest ofmain()(extract + dashboard) still runs and the process exits non-zero only afterComplete.is logged.🛡️ Proposed change
- if (failedRuns > 0) { - console.error(`\n❌ ${failedRuns} run(s) failed. Exiting with error.`); - process.exit(1); - } + if (failedRuns > 0) { + console.error(`\n❌ ${failedRuns} run(s) failed.`); + process.exitCode = 1; + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index-logic.ts` around lines 48 - 52, The code currently calls process.exit(1) when failedRuns > 0 which aborts main() before extractDataFromReports() and generateVisualReport(statsData) run; change this to set process.exitCode = 1 instead so the method still returns and the remaining cleanup/aggregation (calls to extractDataFromReports() and generateVisualReport(statsData) inside main()) execute and the process exits non-zero only after completion. Update the branch that checks failedRuns (the block referencing failedRuns and process.exit(1)) to assign process.exitCode = 1 and remove the immediate hard exit so main() can continue to run its final steps.
🧹 Nitpick comments (9)
src/dashboard/template.ts (2)
111-118: Extract the URL-label helper — same try/catch block is duplicated three times.The exact
new URL(...)→host + pathname + search(withurl-as-fallback) pattern appears at lines 111–118, 181–188, and 195–202. Pulling it into a single helper at module scope reduces noise and ensures the three sites stay in sync if the labelling rule ever changes.♻️ Proposed refactor
+function toUrlLabel(url: string): string { + try { + const u = new URL(url); + return u.host + u.pathname + u.search; + } catch { + return url; + } +} + export function generateHtml(statsData: StatsData): string { const urls = Object.keys(statsData.urls); - const urlLabels = urls.map((u) => { - try { - const url = new URL(u); - return url.host + url.pathname + url.search; - } catch { - return u; - } - }); + const urlLabels = urls.map(toUrlLabel);…and use
toUrlLabel(url)/toUrlLabel(r.url)at the other two sites.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 111 - 118, Extract the duplicated try/catch URL-label logic into a single module-scoped helper named toUrlLabel(url) that attempts new URL(url) and returns url.host + url.pathname + url.search, falling back to the original input on error; then replace the three inline mappings with calls to toUrlLabel (e.g., const urlLabels = urls.map(u => toUrlLabel(u)) and use toUrlLabel(r.url) at the other two sites) so all label generation is centralized and consistent.
45-45: Two micro-perf nits that only matter at scale.
- Line 45:
Math.max(...entries.map((e) => e.ts))spreads every entry into an argument list. Engines blow up around ~100k–1M args (RangeError: Maximum call stack size exceeded). For a Lighthouse dashboard you'll never get there in practice, but areduceform is just as readable and unbounded.- Line 194:
urls.indexOf(r.url)runs insidestatsData.runs.map(...), giving O(runs × urls). Building aMap<string, number>once outside the loop turns it into O(runs + urls).⚡ Proposed tweaks
- const newestTs = Math.max(...entries.map((e) => e.ts)); + const newestTs = entries.reduce((m, e) => (e.ts > m ? e.ts : m), -Infinity);+ const urlIndexByUrl = new Map(urls.map((u, i) => [u, i])); const allRunsData = statsData.runs.map((r) => ({ - urlIndex: urls.indexOf(r.url), + urlIndex: urlIndexByUrl.get(r.url) ?? -1,Also applies to: 193-194
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` at line 45, The current computation of newestTs uses Math.max(...entries.map((e) => e.ts)) which can blow up for very large arrays; replace it with a reduce-based max (scan entries for e.ts) to avoid spreading into the argument list. Also optimize the mapping of run URLs: instead of calling urls.indexOf(r.url) inside statsData.runs.map (O(runs × urls)), prebuild a Map<string, number> from urls to their index once (e.g., urlIndexMap) and use urlIndexMap.get(r.url) inside the runs loop to make it O(runs + urls); update the code paths that reference newestTs, urls, and statsData.runs/r.url accordingly.package.json (1)
18-27:cleanis not run byprepublishOnly/CI, so stale artifacts can ship.
prepublishOnlyrunsnpm run build, which is incremental — if a previous build left an.mjswhose source has since been deleted, esbuild won't remove it andnpm publishwill pick it up via thefilesallowlist. Cheap fix: chaincleanbeforebuildinprepublishOnly.♻️ Proposed change
- "prepublishOnly": "npm run build" + "prepublishOnly": "npm run clean && npm run build"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` around lines 18 - 27, The prepublishOnly script currently runs only "build" and can leave stale artifacts; update the "prepublishOnly" npm script to run the existing "clean" script before "build" (e.g., chain "npm run clean && npm run build") so lighthouse-runner.mjs is removed before building; modify the "prepublishOnly" entry to invoke "clean" then "build" using the "clean", "build", and "prepublishOnly" script names.src/lighthouse.ts (3)
43-46: Consider--headless=newfor Chrome.
--headless(the legacy "old headless") is on a slow path to removal and behaves differently from a real Chrome instance for some web platform features that Lighthouse exercises (service workers, extensions, network code paths). Chrome's recommendation for new automation is--headless=new, which uses the same browser implementation as headed Chrome and gives more representative Lighthouse scores. Low priority since legacy headless still works today, but worth flipping to keep results aligned with real users.♻️ Proposed change
- chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'], + chromeFlags: ['--headless=new', '--no-sandbox', '--disable-gpu'],🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 43 - 46, Update the chrome launch flags to use the newer headless mode: in the chromeLauncher.launch call (function/method: chromeLauncher.launch) where chromeFlags is set (and conditionally using config.chromePath), replace the legacy '--headless' entry with '--headless=new' while retaining the other flags ('--no-sandbox', '--disable-gpu') so Lighthouse runs against the newer headless implementation that matches headed Chrome behavior.
88-99:ttiaudit (interactive) is deprecated since Lighthouse 10 and hidden from the HTML report.The numeric value is still emitted in the JSON, but its score weight is 0 and Google treats TBT/INP/LCP as the supported successors. As you're already on Lighthouse 13, surfacing TTI in the dashboard alongside Core Web Vitals is misleading — consider dropping it (and the corresponding column in the dashboard) or labelling it as legacy. Same advisory applies to
domSize/server-response-time, which are diagnostics rather than scored metrics, but TTI is the one Google has explicitly deprecated.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 88 - 99, The keyMetrics object in src/lighthouse.ts is exposing the deprecated TTI audit ('interactive') and other diagnostic-only metrics; remove the tti entry (safeGet(lhr.audits['interactive'], 'numericValue')) or replace it with a clearly labelled legacy field, and update any dashboard/column logic that reads keyMetrics. Also mark or move domSize (lhr.audits['dom-size']) and serverResponse (lhr.audits['server-response-time']) as diagnostics rather than scored metrics so the UI shows them differently; adjust code that renders columns to drop or label the TTI column and ensure any downstream consumers of keyMetrics handle the changed shape.
13-24:stripScreenshotsmutates the livelhrand is called before persisting both the full and summary outputs.
runLighthousewrites the cleanedlhrto disk and then builds the summary fromcategories/metricsderived earlier, so the summary write is fine. But the mutation does affect any caller that later inspectsrunnerResult.lhr(e.g. if you ever expose it inrunLighthouse's return type). Two minor concerns:
delete lhr.audits[key]while iteratingObject.entries(lhr.audits)is safe (entries snapshot is taken upfront), but the API contract is fragile — a future refactor that switches tofor...inwould silently break it.- There's no defence against
lhritself being non-object (covered today by the truthiness check onrunnerResult, butlhrcould still benullper Lighthouse's loose typing);delete null.fullPageScreenshotwould crash outside the per-iterationtry/catchifrunnerResult.lhris unexpectedly nullish.Cheap hardening:
♻️ Proposed change
function stripScreenshots(lhr: any): any { + if (!lhr || typeof lhr !== 'object') return lhr; delete lhr.fullPageScreenshot; if (lhr.audits) { - for (const [key, audit] of Object.entries(lhr.audits)) { + for (const key of Object.keys(lhr.audits)) { + const audit = lhr.audits[key]; const a = audit as any; if (a.details && a.details.type === 'screenshot') { delete lhr.audits[key]; } } } return lhr; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 13 - 24, stripScreenshots currently mutates the passed-in lhr and can throw if lhr is nullish; change stripScreenshots to defensively handle null/undefined lhr and return a new cleaned copy instead of mutating the input: first check typeof lhr === 'object' and truthiness, shallow-clone the top-level lhr (e.g., {...lhr}), then if audits exist build a new audits object by iterating Object.keys(lhr.audits) or filtering entries and copying only those where audit.details?.type !== 'screenshot' (do not use delete while iterating), also drop fullPageScreenshot from the new copy, and return the new cleaned LHR so callers (and runnerResult.lhr) are not modified in place.src/compression.ts (2)
90-93: Non-atomic write to the report HTML — a crash mid-write corrupts the file.
fs.writeFileSync(filePath, content)overwrites the report in place. If the process is killed (CI timeout, OOM, SIGINT during a long compression batch) between the truncation and the final write, the user is left with a half-written or empty*.htmlwhose only backup is whatever lives in git. Cheap fix: write to${filePath}.tmpthenfs.renameSync(atomic on POSIX, atomic-ish on Windows for same-volume).🛡️ Proposed change
- if (replacements.size > 0) { - content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); - fs.writeFileSync(filePath, content); - } + if (replacements.size > 0) { + content = content.replace(imgRegex, (m) => replacements.get(m) ?? m); + const tmp = `${filePath}.tmp`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, filePath); + }Same applies to
fs.writeFileSync(filePath, content)at line 27 insidefixDoublePrefixesInReport.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` around lines 90 - 93, The report HTML is overwritten in-place causing corruption if the process crashes; change both locations that call fs.writeFileSync(filePath, content) (the replacement block in the compression routine and the write inside fixDoublePrefixesInReport) to perform an atomic write by writing to a temp path (e.g., `${filePath}.tmp` or a unique temp name), fs.writeFileSync the temp file, then fs.renameSync the temp to filePath so the final replace is atomic across POSIX/Windows same-volume moves; ensure the temp write uses the same permissions and that any temp file is cleaned up on failures.
41-46:imgRegexmay capture trailing data-URI delimiters in some embedding contexts.The negated character class
[^"'\s>}\]\)]+permits,,;,&,<,=, etc. Standard base64 doesn't emit those (only[A-Za-z0-9+/=]), so today's Lighthouse output is fine — but if Lighthouse ever embeds two data URIs concatenated (a pattern this very file is repairing infixDoublePrefixesInReport), the regex will greedily match past the first one's terminator. Tightening the class to[A-Za-z0-9+/=]+matches base64's actual alphabet and is strictly safer.♻️ Proposed change
- const imgRegex = /data:image\/(jpeg|png|webp);base64,([^"'\s>}\]\)]+)/g; + const imgRegex = /data:image\/(jpeg|png|webp);base64,([A-Za-z0-9+/=]+)/g;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/compression.ts` around lines 41 - 46, The imgRegex currently uses a broad negated class which can greedily capture beyond a single data URI; update the regular expression used in imgRegex to restrict the captured data to the Base64 alphabet (A-Za-z0-9+/=) so it matches only valid base64 payloads and avoids swallowing trailing delimiters (this affects the logic that later manipulates matches such as in fixDoublePrefixesInReport and the replacements Map). Locate the const imgRegex declaration and replace its character class with one matching base64 characters only, then run tests that exercise image data URI handling to verify double-prefix repairs and replacements still behave correctly.src/index-logic.ts (1)
29-44: Index-based loops add noise wherefor...ofreads cleaner.The
uIdx/mIdxindices exist only to computeisLast; the rest of the body would be more idiomatic withfor...of. You can keep theisLastlogic without index bookkeeping by usingentries():♻️ Optional refactor
- for (let uIdx = 0; uIdx < config.urls.length; uIdx++) { - const url = config.urls[uIdx]; - for (let mIdx = 0; mIdx < config.emulations.length; mIdx++) { - const mode = config.emulations[mIdx]; + for (const [uIdx, url] of config.urls.entries()) { + for (const [mIdx, mode] of config.emulations.entries()) { try { await runLighthouse(url, i, mode, runId); } catch (err: any) { console.error(` ⚠️ Failed ${url} (${mode}): ${err.message || err.toString()}`); failedRuns++; } const isLast = i === iterations && uIdx === config.urls.length - 1 && mIdx === config.emulations.length - 1; if (!isLast) { console.log(`Waiting ${config.delay}s...`); await sleep(config.delay * 1000); } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/index-logic.ts` around lines 29 - 44, Replace the index-based loops that use uIdx/mIdx with idiomatic for...of + entries() while preserving behavior: iterate URLs with for (const [uIdx, url] of config.urls.entries()) and emulations with for (const [mIdx, mode] of config.emulations.entries()) so you can compute isLast as i === iterations && uIdx === config.urls.length - 1 && mIdx === config.emulations.length - 1; keep the existing try/catch that calls runLighthouse(url, i, mode, runId) and increments failedRuns on error, keep the console logging and the sleep(config.delay * 1000) when not isLast, and reuse the same local variables (url, mode, uIdx, mIdx) to avoid changing other logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 5-7: The `@types/node` devDependency is pinned to ^25.0.0 while
engines.node declares "node": ">=22.0.0", which risks allowing type definitions
for Node APIs not present in Node 22; update the `@types/node` entry in
package.json to a version matching the minimum engine major (e.g., change
"@types/node": "^25.0.0" to "^22.0.0" or the exact 22.x range you support), run
your package manager install to update lockfiles, and re-run the TypeScript
typecheck/CI to ensure no accidental usage of newer Node 24/25-only types
remain; adjust any typings usage accordingly in source if type errors surface.
In `@src/compression.ts`:
- Around line 99-103: The compressAllReports function currently calls
fs.readdirSync(runsDir) which throws ENOENT when runsDir does not exist; update
compressAllReports to first check fs.existsSync(runsDir) and short-circuit if
false (log or return early) to avoid the exception. Locate compressAllReports
and use runsDir and fs.existsSync to guard the readdirSync call so the function
no-ops (or logs "no reports to compress") when the directory is missing. Ensure
any callers (e.g., index-logic.ts compress-only path) still behave correctly by
returning without attempting to read files.
- Around line 47-87: The tmp filename scheme using uid =
`${Date.now()}_${matchCounter}` in compressAllReports is prone to collisions
across processes; change the temp allocation to be process-unique (e.g., include
process.pid and a small random suffix) or, better, create a per-run temp
directory with fs.mkdtempSync (use a prefix like 'lhr_img_') and place
tmpIn/tmpOut inside it, then remove that directory in a top-level finally;
update references to uid/tmpIn/tmpOut and keep existing cleanup logic to unlink
files inside that isolated dir.
In `@src/dashboard/template.ts`:
- Around line 193-216: The map that builds allRunsData is recomputing the URL
label inline; replace the IIFE URL parsing with the existing pre-computed field
r.urlLabel (from RunData) and ensure any references in the summary object use
r.urlLabel as well (e.g., in the top-level urlLabel and summary.urlLabel);
remove the redundant getUrlLabel-like logic to avoid duplication and rely on the
pre-populated r.urlLabel value.
In `@src/index-logic.ts`:
- Around line 9-12: The compress-only path crashes because compressAllReports
calls fs.readdirSync(runsDir) without guarding missing runsDir; update
compressAllReports to first check for the directory (e.g. fs.existsSync(runsDir)
or use try/catch around readdirSync) and if it doesn't exist, log/return a
friendly "no reports to compress" message and exit gracefully; this makes the
compress-only branch in index-logic (the if (args.compress) {
compressAllReports(args.quality); return; }) safe and also protects the audit
path that assumes runsDir exists.
In `@src/lighthouse.ts`:
- Around line 81-86: The code that builds the categories object dereferences
lhr.categories.performance, .accessibility, etc., which can throw if
lhr.categories is missing; change the categories construction in the function
that defines the categories constant so each safeGet receives the category
object (which may be undefined) or use optional chaining — for example replace
safeGet(lhr.categories.performance, 'score') with
safeGet(lhr.categories?.performance, 'score') (or safeGet(safeGet(lhr,
'categories')?.performance, 'score')) for all four entries so missing categories
won’t cause a TypeError before safeGet runs.
---
Duplicate comments:
In `@src/index-logic.ts`:
- Around line 48-52: The code currently calls process.exit(1) when failedRuns >
0 which aborts main() before extractDataFromReports() and
generateVisualReport(statsData) run; change this to set process.exitCode = 1
instead so the method still returns and the remaining cleanup/aggregation (calls
to extractDataFromReports() and generateVisualReport(statsData) inside main())
execute and the process exits non-zero only after completion. Update the branch
that checks failedRuns (the block referencing failedRuns and process.exit(1)) to
assign process.exitCode = 1 and remove the immediate hard exit so main() can
continue to run its final steps.
---
Nitpick comments:
In `@package.json`:
- Around line 18-27: The prepublishOnly script currently runs only "build" and
can leave stale artifacts; update the "prepublishOnly" npm script to run the
existing "clean" script before "build" (e.g., chain "npm run clean && npm run
build") so lighthouse-runner.mjs is removed before building; modify the
"prepublishOnly" entry to invoke "clean" then "build" using the "clean",
"build", and "prepublishOnly" script names.
In `@src/compression.ts`:
- Around line 90-93: The report HTML is overwritten in-place causing corruption
if the process crashes; change both locations that call
fs.writeFileSync(filePath, content) (the replacement block in the compression
routine and the write inside fixDoublePrefixesInReport) to perform an atomic
write by writing to a temp path (e.g., `${filePath}.tmp` or a unique temp name),
fs.writeFileSync the temp file, then fs.renameSync the temp to filePath so the
final replace is atomic across POSIX/Windows same-volume moves; ensure the temp
write uses the same permissions and that any temp file is cleaned up on
failures.
- Around line 41-46: The imgRegex currently uses a broad negated class which can
greedily capture beyond a single data URI; update the regular expression used in
imgRegex to restrict the captured data to the Base64 alphabet (A-Za-z0-9+/=) so
it matches only valid base64 payloads and avoids swallowing trailing delimiters
(this affects the logic that later manipulates matches such as in
fixDoublePrefixesInReport and the replacements Map). Locate the const imgRegex
declaration and replace its character class with one matching base64 characters
only, then run tests that exercise image data URI handling to verify
double-prefix repairs and replacements still behave correctly.
In `@src/dashboard/template.ts`:
- Around line 111-118: Extract the duplicated try/catch URL-label logic into a
single module-scoped helper named toUrlLabel(url) that attempts new URL(url) and
returns url.host + url.pathname + url.search, falling back to the original input
on error; then replace the three inline mappings with calls to toUrlLabel (e.g.,
const urlLabels = urls.map(u => toUrlLabel(u)) and use toUrlLabel(r.url) at the
other two sites) so all label generation is centralized and consistent.
- Line 45: The current computation of newestTs uses Math.max(...entries.map((e)
=> e.ts)) which can blow up for very large arrays; replace it with a
reduce-based max (scan entries for e.ts) to avoid spreading into the argument
list. Also optimize the mapping of run URLs: instead of calling
urls.indexOf(r.url) inside statsData.runs.map (O(runs × urls)), prebuild a
Map<string, number> from urls to their index once (e.g., urlIndexMap) and use
urlIndexMap.get(r.url) inside the runs loop to make it O(runs + urls); update
the code paths that reference newestTs, urls, and statsData.runs/r.url
accordingly.
In `@src/index-logic.ts`:
- Around line 29-44: Replace the index-based loops that use uIdx/mIdx with
idiomatic for...of + entries() while preserving behavior: iterate URLs with for
(const [uIdx, url] of config.urls.entries()) and emulations with for (const
[mIdx, mode] of config.emulations.entries()) so you can compute isLast as i ===
iterations && uIdx === config.urls.length - 1 && mIdx ===
config.emulations.length - 1; keep the existing try/catch that calls
runLighthouse(url, i, mode, runId) and increments failedRuns on error, keep the
console logging and the sleep(config.delay * 1000) when not isLast, and reuse
the same local variables (url, mode, uIdx, mIdx) to avoid changing other logic.
In `@src/lighthouse.ts`:
- Around line 43-46: Update the chrome launch flags to use the newer headless
mode: in the chromeLauncher.launch call (function/method: chromeLauncher.launch)
where chromeFlags is set (and conditionally using config.chromePath), replace
the legacy '--headless' entry with '--headless=new' while retaining the other
flags ('--no-sandbox', '--disable-gpu') so Lighthouse runs against the newer
headless implementation that matches headed Chrome behavior.
- Around line 88-99: The keyMetrics object in src/lighthouse.ts is exposing the
deprecated TTI audit ('interactive') and other diagnostic-only metrics; remove
the tti entry (safeGet(lhr.audits['interactive'], 'numericValue')) or replace it
with a clearly labelled legacy field, and update any dashboard/column logic that
reads keyMetrics. Also mark or move domSize (lhr.audits['dom-size']) and
serverResponse (lhr.audits['server-response-time']) as diagnostics rather than
scored metrics so the UI shows them differently; adjust code that renders
columns to drop or label the TTI column and ensure any downstream consumers of
keyMetrics handle the changed shape.
- Around line 13-24: stripScreenshots currently mutates the passed-in lhr and
can throw if lhr is nullish; change stripScreenshots to defensively handle
null/undefined lhr and return a new cleaned copy instead of mutating the input:
first check typeof lhr === 'object' and truthiness, shallow-clone the top-level
lhr (e.g., {...lhr}), then if audits exist build a new audits object by
iterating Object.keys(lhr.audits) or filtering entries and copying only those
where audit.details?.type !== 'screenshot' (do not use delete while iterating),
also drop fullPageScreenshot from the new copy, and return the new cleaned LHR
so callers (and runnerResult.lhr) are not modified in place.
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ddcd0d55-0334-42ab-b68f-3e50d61873b5
⛔ Files ignored due to path filters (2)
devenv.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (19)
.github/workflows/release.yml.gitignorebuild.jsdevenv.nixjustfilelighthouse-runner.jspackage.jsonsrc/compression.tssrc/config.tssrc/dashboard/client.tssrc/dashboard/index.tssrc/dashboard/styles.tssrc/dashboard/template.tssrc/data-extract.tssrc/index-logic.tssrc/index.tssrc/lighthouse.tssrc/utils.tstsconfig.json
💤 Files with no reviewable changes (1)
- lighthouse-runner.js
✅ Files skipped from review due to trivial changes (8)
- devenv.nix
- .gitignore
- .github/workflows/release.yml
- src/dashboard/styles.ts
- tsconfig.json
- src/utils.ts
- justfile
- src/dashboard/client.ts
🚧 Files skipped from review as they are similar to previous changes (5)
- src/index.ts
- src/dashboard/index.ts
- build.js
- src/config.ts
- src/data-extract.ts
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 5 file(s) based on 6 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 5 file(s) based on 6 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (7)
package.json (1)
26-26:prepublishOnlyruns unminifiedbuild— published CLI bundle will be unminified.
prepublishOnlyinvokesnpm run build, which callsnode build.jswithout the--minifyflag. The artifact uploaded to the npm registry (and thebinconsumers install) will therefore be the dev/debug bundle. If a minified release is intended (the existence of a dedicatedbuild:minifyscript suggests so), switchprepublishOnlyto it, or fold minification into the default build forprepublishOnly.♻️ Proposed change
- "prepublishOnly": "npm run build" + "prepublishOnly": "npm run clean && npm run build:minify"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@package.json` at line 26, The prepublishOnly script currently invokes "npm run build" which runs the unminified build; update package.json so prepublishOnly references the minified build script (e.g., replace "prepublishOnly": "npm run build" with "prepublishOnly": "npm run build:minify") or alter the "build" script to include minification; locate the "prepublishOnly" key and either point it to "build:minify" or modify the "build" script to call the minifier so published CLI bundles are minified.src/dashboard/template.ts (4)
12-21:!isNaN(val)accepts numeric strings as valid values.
isNaNcoerces its argument:isNaN('42')isfalse, so a stringly-typed metric (e.g., a JSON parse oddity, or a future schema change) would slip through and then poisonreduce((a, b) => a + b, 0)via implicit+concatenation (0 + '42' === '042'). Switching toNumber.isFinite(val)rejects strings andInfinity/NaNnumerically, which matches the intent. Same change applies tocalcWeightedAvgon line 38.🛡️ Suggested change
- return val != null && !isNaN(val) ? val : null; + return typeof val === 'number' && Number.isFinite(val) ? val : null;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 12 - 21, The current validation in calcAvg allows numeric strings because it uses !isNaN(val); update the check to ensure only real numeric values are accepted by replacing the isNaN-based guard with Number.isFinite(val) (which rejects numeric strings, Infinity and NaN) and likewise update calcWeightedAvg to use Number.isFinite for its value and weight checks; locate and modify the validation inside calcAvg (the mapping/filter step referencing prefix and key) and the analogous mapping/filter in calcWeightedAvg so only finite numbers proceed to reduce/sum.
109-118: ReusegetUrlLabel/cached labels instead of duplicating URL parsing.The IIFE on lines 112–118 reimplements the exact logic of
getUrlLabelalready exported fromsrc/utils.ts(and which also populatesstatsData.urls[u].label). The same IIFE is duplicated again at lines 181–188 insidetrendData, even thoughurlLabelis already computed on line 162 within the samemapcallback. Three opportunities to dedupe:♻️ Suggested cleanup
+import { StatsData, IterationEntry, getUrlLabel } from '../utils.js'; ... - const urlLabels = urls.map((u) => { - try { - const url = new URL(u); - return url.host + url.pathname + url.search; - } catch { - return u; - } - }); + const urlLabels = urls.map((u) => statsData.urls[u]?.label ?? getUrlLabel(u)); ... - return { - label: (() => { - try { - const u = new URL(url); - return u.host + u.pathname + u.search; - } catch { - return url; - } - })(), - modes: modesTrend - }; + return { label: urlLabel, modes: modesTrend };Also applies to: 181-188
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 109 - 118, The generateHtml function duplicates URL-label logic instead of reusing the existing getUrlLabel and cached labels on statsData.urls; replace the inline URL parsing IIFE used for urlLabels and the duplicate logic inside trendData with calls to getUrlLabel(statsData, u) (or read statsData.urls[u].label if present) and reuse the already computed urlLabel in the map callback so label computation is centralized (update references to urlLabels, urlLabel, trendData and statsData.urls accordingly).
223-234:safeInjectdoesn't escape U+2028 / U+2029 — minor JS-in-script gotcha.
JSON.stringifydoes not escape the line-separator code points U+2028 and U+2029, which are valid in JSON strings but terminate statements when parsed as JavaScript. If any URL instatsData(or any string value) ever contains one of those characters, the inlined<script>payload becomes a syntax error and the entire dashboard fails to load. Cheap to fix while you're already post-processing the JSON:🛡️ Suggested hardening
- const safeInject = (data: any) => JSON.stringify(data).replace(/<\/script>/g, '<\\/script>'); + const safeInject = (data: any) => + JSON.stringify(data) + .replace(/<\/script>/gi, '<\\/script>') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029');🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 223 - 234, The safeInject function currently only escapes "</script>" but JSON.stringify output can contain U+2028 and U+2029 which break JS when inlined; update safeInject (used by clientInjected) to additionally replace U+2028 and U+2029 with their escaped forms "\\u2028" and "\\u2029" (in addition to the existing </script> replacement) so that any string values injected into the inlined client JS remain valid JavaScript.
244-251: Dashboard pulls all chart libraries from third-party CDNs at runtime.The generated HTML loads Chart.js, chartjs-plugin-zoom, chartjs-chart-boxplot, noUiSlider, Luxon, chartjs-adapter-luxon, and Google Fonts via
cdn.jsdelivr.net/cdnjs.cloudflare.com/fonts.googleapis.com. This means the dashboard:
- Doesn't work offline or in air-gapped CI environments where the report is meant to be archived.
- Has no SRI
integrityattribute — a CDN compromise or version-tag retag could silently inject JS into anyone viewing an old report.- Is subject to CDN downtime; cached/archived reports break months later when a CDN drops a tag.
For a build-tooling project that's already producing a bundled output, consider either bundling these via the existing build (esbuild) into the static HTML or at minimum adding
integrity+crossoriginattributes pinned to the exact published hash.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/dashboard/template.ts` around lines 244 - 251, The template currently injects external resources directly (e.g. script tags for "https://cdn.jsdelivr.net/npm/chart.js@4.4.2", chartjs-plugin-zoom, chartjs-chart-boxplot, noUiSlider, Luxon, chartjs-adapter-luxon and the Google Fonts link) which breaks offline/archive use and lacks SRI; fix by either bundling these dependencies into the generated dashboard HTML via the existing build (esbuild) pipeline and replacing the external <script>/<link> tags with the inlined/bundled assets, or if bundling is not possible, add pinned Subresource Integrity (integrity) and crossorigin attributes for each external tag and pin exact published hashes (and provide a local fallback for fonts/assets). Locate the injected tags in src/dashboard/template.ts and update the code that returns the HTML string to emit the bundled assets or to include integrity+crossorigin+fallbacks for each URL.src/lighthouse.ts (2)
43-46: Chrome instance can leak if any code between launch and try throws.
chromeLauncher.launchis awaited outside thetryblock, so if any synchronous code on lines 48–72 (orgetUrlHash) ever throws after the browser starts but before enteringtry, thefinallywon't run and the Chrome process is orphaned. Currently the intervening code is benign, but moving the launch inside thetry(or wrapping the post-launch code in its own guard) makes the lifecycle robust to future edits.🛡️ Suggested change
-export async function runLighthouse(url: string, iteration: number, mode: string, runId?: number): Promise<void> { - const chrome = await chromeLauncher.launch({ - ...(config.chromePath ? { chromePath: config.chromePath } : {}), - chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'], - }); - - const isDesktop = mode === 'desktop'; - ... - const urlHash = getUrlHash(url); - console.log(`\n🚀 [${mode.toUpperCase()}] Audit: ${url} (ID: ${urlHash})`); - - try { +export async function runLighthouse(url: string, iteration: number, mode: string, runId?: number): Promise<void> { + const chrome = await chromeLauncher.launch({ + ...(config.chromePath ? { chromePath: config.chromePath } : {}), + chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu'], + }); + try { + const isDesktop = mode === 'desktop'; + // ... (move all subsequent setup inside try)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 43 - 46, Move the chromeLauncher.launch call into the try block (or declare let chrome; before launch and perform chrome = await chromeLauncher.launch(...) inside the try) so that any exceptions thrown by intervening synchronous code (including getUrlHash) cannot orphan the Chrome process; ensure the existing finally block checks for chrome and properly calls chrome.kill() (or chrome.process.kill()) to guarantee cleanup even if launch or post-launch code throws.
4-4: Simplify by passingdesktopConfigdirectly without manual reconstruction.The current code spreads
desktopConfigand then overridesthrottlingMethodinsettings. Since you already passformFactor: 'desktop'andthrottlingMethod: 'provided'viaoptionson lines 56–57, you can let Lighthouse mergeoptionsover the config'ssettings(its documented behavior). Consider passingdesktopConfigdirectly as the config object instead of reconstructing it.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` at line 4, The code is needlessly reconstructing the config by spreading desktopConfig and overriding settings (throttlingMethod) even though you already set formFactor and throttlingMethod via options; instead, pass desktopConfig directly as the config object and let Lighthouse merge options over desktopConfig.settings. Remove the manual reconstruction of settings and any explicit throttlingMethod override, keep using options with formFactor: 'desktop' and throttlingMethod: 'provided', and ensure the call that currently uses the reconstructed config now receives desktopConfig.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@package.json`:
- Around line 5-7: The package's engines.node (package.json "engines": "node":
">=22.0.0") is inconsistent with the esbuild target (build.js using target:
'node24'); either lower the esbuild target to 'node22' or raise the engine floor
to '>=24.0.0'. Fix by editing build.js to change the esbuild target from
'node24' to 'node22' if you intend to support Node 22, or edit package.json to
change "engines".node to ">=24.0.0" if you require Node 24 features; reference
the unique symbols "engines.node" in package.json and "target" in build.js to
locate the changes. Ensure the chosen option is applied consistently before
publishing to avoid runtime syntax/reference errors on Node 22.
In `@src/lighthouse.ts`:
- Around line 88-99: The keyMetrics construction is still reading the removed
Lighthouse audit id 'dom-size', causing domSize to be always null; update the
domSize entry in the keyMetrics object to read lhr.audits['dom-size-insight']
using the existing safeGet helper (i.e., replace the 'dom-size' key with
'dom-size-insight' in the domSize line inside the keyMetrics definition in
src/lighthouse.ts where safeGet(lhr.audits['dom-size'], 'numericValue') is
used).
---
Nitpick comments:
In `@package.json`:
- Line 26: The prepublishOnly script currently invokes "npm run build" which
runs the unminified build; update package.json so prepublishOnly references the
minified build script (e.g., replace "prepublishOnly": "npm run build" with
"prepublishOnly": "npm run build:minify") or alter the "build" script to include
minification; locate the "prepublishOnly" key and either point it to
"build:minify" or modify the "build" script to call the minifier so published
CLI bundles are minified.
In `@src/dashboard/template.ts`:
- Around line 12-21: The current validation in calcAvg allows numeric strings
because it uses !isNaN(val); update the check to ensure only real numeric values
are accepted by replacing the isNaN-based guard with Number.isFinite(val) (which
rejects numeric strings, Infinity and NaN) and likewise update calcWeightedAvg
to use Number.isFinite for its value and weight checks; locate and modify the
validation inside calcAvg (the mapping/filter step referencing prefix and key)
and the analogous mapping/filter in calcWeightedAvg so only finite numbers
proceed to reduce/sum.
- Around line 109-118: The generateHtml function duplicates URL-label logic
instead of reusing the existing getUrlLabel and cached labels on statsData.urls;
replace the inline URL parsing IIFE used for urlLabels and the duplicate logic
inside trendData with calls to getUrlLabel(statsData, u) (or read
statsData.urls[u].label if present) and reuse the already computed urlLabel in
the map callback so label computation is centralized (update references to
urlLabels, urlLabel, trendData and statsData.urls accordingly).
- Around line 223-234: The safeInject function currently only escapes
"</script>" but JSON.stringify output can contain U+2028 and U+2029 which break
JS when inlined; update safeInject (used by clientInjected) to additionally
replace U+2028 and U+2029 with their escaped forms "\\u2028" and "\\u2029" (in
addition to the existing </script> replacement) so that any string values
injected into the inlined client JS remain valid JavaScript.
- Around line 244-251: The template currently injects external resources
directly (e.g. script tags for "https://cdn.jsdelivr.net/npm/chart.js@4.4.2",
chartjs-plugin-zoom, chartjs-chart-boxplot, noUiSlider, Luxon,
chartjs-adapter-luxon and the Google Fonts link) which breaks offline/archive
use and lacks SRI; fix by either bundling these dependencies into the generated
dashboard HTML via the existing build (esbuild) pipeline and replacing the
external <script>/<link> tags with the inlined/bundled assets, or if bundling is
not possible, add pinned Subresource Integrity (integrity) and crossorigin
attributes for each external tag and pin exact published hashes (and provide a
local fallback for fonts/assets). Locate the injected tags in
src/dashboard/template.ts and update the code that returns the HTML string to
emit the bundled assets or to include integrity+crossorigin+fallbacks for each
URL.
In `@src/lighthouse.ts`:
- Around line 43-46: Move the chromeLauncher.launch call into the try block (or
declare let chrome; before launch and perform chrome = await
chromeLauncher.launch(...) inside the try) so that any exceptions thrown by
intervening synchronous code (including getUrlHash) cannot orphan the Chrome
process; ensure the existing finally block checks for chrome and properly calls
chrome.kill() (or chrome.process.kill()) to guarantee cleanup even if launch or
post-launch code throws.
- Line 4: The code is needlessly reconstructing the config by spreading
desktopConfig and overriding settings (throttlingMethod) even though you already
set formFactor and throttlingMethod via options; instead, pass desktopConfig
directly as the config object and let Lighthouse merge options over
desktopConfig.settings. Remove the manual reconstruction of settings and any
explicit throttlingMethod override, keep using options with formFactor:
'desktop' and throttlingMethod: 'provided', and ensure the call that currently
uses the reconstructed config now receives desktopConfig.
🪄 Autofix (Beta)
✅ Autofix completed
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a1e35510-b17a-4321-a776-2a668f506fe3
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (4)
package.jsonsrc/compression.tssrc/dashboard/template.tssrc/lighthouse.ts
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Fixes Applied SuccessfullyFixed 2 file(s) based on 2 unresolved review comments. Files modified:
Commit: The changes have been pushed to the Time taken: |
Fixed 2 file(s) based on 2 unresolved review comments. Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
build.js (2)
36-39: Optional: dispose the watch context on shutdown.
ctxis created but never disposed. For a long-running CLI watcher this is generally fine, but adding a SIGINT/SIGTERM handler that callsctx.dispose()makes the process exit cleanly and releases the esbuild service.♻️ Optional cleanup
if (watch) { const ctx = await esbuild.context(buildOptions); await ctx.watch(); console.log('Watching for changes...'); + const shutdown = async () => { + await ctx.dispose(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` around lines 36 - 39, When starting the watcher via esbuild.context(buildOptions) and assigning it to ctx, add shutdown handlers for SIGINT and SIGTERM that call await ctx.dispose() (and fallback ctx.dispose().catch(...)) to cleanly release the esbuild service before exit; register the handlers right after creating ctx, ensure they run only once (remove listeners after invoked) and, if appropriate, call process.exit() after disposal so the CLI exits cleanly.
41-44: Remove dead error check:esbuild.build()rejects on errors by default.In esbuild 0.25,
esbuild.build()rejects the promise when build errors occur, so theresult.errors.length > 0check at lines 42–44 is unreachable. The rejection is already caught at line 46. Simplify by removing the dead code:♻️ Proposed simplification
} else { - const result = await esbuild.build(buildOptions); - if (result.errors.length > 0) { - process.exit(1); - } + await esbuild.build(buildOptions); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@build.js` around lines 41 - 44, Remove the unreachable error check after calling esbuild.build(buildOptions): the promise rejects on build errors, so delete the conditional block that checks result.errors.length > 0 (and any use of the unreachable `result` error path) and rely on the existing try/catch around the esbuild.build(...) call; update any references to `result` only if they’re used later, otherwise eliminate the unused `result` variable.src/lighthouse.ts (1)
102-111: DRY: derivesummaryPathfromfileNameinstead of rebuilding the stem.Lines 103 and 110 hand-construct the same
${urlHash}-${mode}-${timestamp}-iter${iteration}stem. Any future change to the naming scheme (e.g., addingrunIdto the stem) has to be made in two places, and the two paths can silently drift out of sync. A single source of truth keeps them paired.♻️ Proposed change
- const timestamp = Date.now(); - const fileName = `${urlHash}-${mode}-${timestamp}-iter${iteration}.json`; - - const reportFilePath = path.join(runsDir, fileName); - const cleanedLhr = stripScreenshots(lhr); - fs.writeFileSync(reportFilePath, JSON.stringify(cleanedLhr, null, 2)); - - const summary = createSummary(url, urlHash, mode, timestamp, fileName, categories, metrics, iteration, runId); - const summaryPath = path.join(runsDir, `${urlHash}-${mode}-${timestamp}-iter${iteration}.summary.json`); - fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2)); + const timestamp = Date.now(); + const stem = `${urlHash}-${mode}-${timestamp}-iter${iteration}`; + const fileName = `${stem}.json`; + + const reportFilePath = path.join(runsDir, fileName); + const cleanedLhr = stripScreenshots(lhr); + fs.writeFileSync(reportFilePath, JSON.stringify(cleanedLhr, null, 2)); + + const summary = createSummary(url, urlHash, mode, timestamp, fileName, categories, metrics, iteration, runId); + const summaryPath = path.join(runsDir, `${stem}.summary.json`); + fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lighthouse.ts` around lines 102 - 111, The file writes two artifacts using the same stem but reconstructs it twice; derive the summary file name from the already-built fileName to avoid drift: compute summaryPath by taking fileName (or its basename without extension) and appending ".summary.json" (using existing variables fileName and runsDir) instead of rebuilding `${urlHash}-${mode}-${timestamp}-iter${iteration}`; update the code around fileName, reportFilePath, and summaryPath (and the createSummary call) so summaryPath is produced from fileName and then write the summary to that path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/lighthouse.ts`:
- Around line 13-24: The stripScreenshots function currently only removes audits
whose details.type === 'screenshot' but misses filmstrip thumbnails; update
stripScreenshots to also remove audits whose details.type === 'filmstrip'
(and/or whose audit id/name corresponds to screenshot-thumbnails) by checking
aOR condition for details.type === 'screenshot' || details.type === 'filmstrip'
when iterating lhr.audits and deleting matching entries (keep deleting
lhr.fullPageScreenshot as-is and return lhr).
---
Nitpick comments:
In `@build.js`:
- Around line 36-39: When starting the watcher via esbuild.context(buildOptions)
and assigning it to ctx, add shutdown handlers for SIGINT and SIGTERM that call
await ctx.dispose() (and fallback ctx.dispose().catch(...)) to cleanly release
the esbuild service before exit; register the handlers right after creating ctx,
ensure they run only once (remove listeners after invoked) and, if appropriate,
call process.exit() after disposal so the CLI exits cleanly.
- Around line 41-44: Remove the unreachable error check after calling
esbuild.build(buildOptions): the promise rejects on build errors, so delete the
conditional block that checks result.errors.length > 0 (and any use of the
unreachable `result` error path) and rely on the existing try/catch around the
esbuild.build(...) call; update any references to `result` only if they’re used
later, otherwise eliminate the unused `result` variable.
In `@src/lighthouse.ts`:
- Around line 102-111: The file writes two artifacts using the same stem but
reconstructs it twice; derive the summary file name from the already-built
fileName to avoid drift: compute summaryPath by taking fileName (or its basename
without extension) and appending ".summary.json" (using existing variables
fileName and runsDir) instead of rebuilding
`${urlHash}-${mode}-${timestamp}-iter${iteration}`; update the code around
fileName, reportFilePath, and summaryPath (and the createSummary call) so
summaryPath is produced from fileName and then write the summary to that path.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 908b6c64-4182-4ea7-a982-05abaaa0e0f9
📒 Files selected for processing (2)
build.jssrc/lighthouse.ts
| function stripScreenshots(lhr: any): any { | ||
| delete lhr.fullPageScreenshot; | ||
| if (lhr.audits) { | ||
| for (const [key, audit] of Object.entries(lhr.audits)) { | ||
| const a = audit as any; | ||
| if (a.details && a.details.type === 'screenshot') { | ||
| delete lhr.audits[key]; | ||
| } | ||
| } | ||
| } | ||
| return lhr; | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Sanity-check the audit IDs / details.type values present in a sample run, if any exist in repo.
fd -e json -t f | head -20
fd -e json -t f --exec sh -c 'jq -r ".audits | to_entries[] | select(.value.details) | \"\(.key)\t\(.value.details.type)\"" "$1" 2>/dev/null | sort -u' _ {} \; | sort -u | head -50Repository: freke/light-house-report
Length of output: 124
🌐 Web query:
Lighthouse screenshot-thumbnails audit details type filmstrip
💡 Result:
The Lighthouse "screenshot-thumbnails" audit is a diagnostics audit that provides a filmstrip view of the page load process. It generates thumbnails from screenshots captured during the trace at evenly spaced intervals (typically 8-10 thumbnails). In the Lighthouse Result (LHR) JSON, the audit's details object has type: "filmstrip". The structure is: { "type": "filmstrip", "scale": number, // Scale factor for rendering the filmstrip "items": [ { "timing": number, // Relative time from navigationStart in milliseconds "timestamp": number, // Raw timestamp in microseconds "data": string // Base64-encoded JPEG thumbnail data (data URL) }, ... ] } This data renders as a horizontal filmstrip in the HTML report under the Diagnostics section, helping visualize rendering progress over time (e.g., identifying when content appears). Thumbnails are scaled down (e.g., ~120x213px originally, improved resolution in later versions) for performance. Example usage to extract images (from sources): audits['screenshot-thumbnails'].details.items.forEach(({ timing, data }) => { const base64 = data.split(';base64,').pop; // Save as screenshot-${timing}ms.jpg }); The audit is informative (scoreDisplayMode: "informative", score: null) and relies on the ScreenshotFilmstrip artifact from the trace.
Citations:
- 1: screenshot-thumbnails perf: cache more aggressively GoogleChrome/lighthouse#5957
- 2: https://github.com/GoogleChrome/lighthouse/blob/main/docs/understanding-results.md
- 3: https://leonardofaria.net/2020/11/30/generating-screenshots-with-lighthouse
- 4: https://github.com/GoogleChrome/lighthouse/blob/master/types/lhr/audit-details.d.ts
- 5: https://cdn.jsdelivr.net/npm/lighthouse@7.3.0/types/audit-details.d.ts
🌐 Web query:
Lighthouse audit details.type filmstrip documentation
💡 Result:
In Lighthouse audit results (LHR JSON), the details object for filmstrip audits has type: 'filmstrip'. This is one of the supported detail types defined in the Lighthouse type definitions. The structure is: interface Filmstrip extends BaseDetails { type: 'filmstrip'; scale: number; items: Array<{ timing: number; // Relative time from navigationStart, in milliseconds timestamp: number; // Raw timestamp, in microseconds data: string; // Data URL of the frame }>; } This details type is used in performance audits to visualize page rendering progress via screenshots captured during page load. It extends from BaseDetails, which includes common properties like type and overallSavingsMs where applicable, but primarily provides the filmstrip items array for rendering the timeline view in HTML reports or custom viewers. Filmstrips appear in the Performance section of Lighthouse reports (e.g., in Chrome DevTools or HTML output) to correlate visual progress with metrics like Speed Index or LCP. To access raw filmstrip data programmatically, run Lighthouse with --save-assets or in gatherMode to retrieve traces/screenshots. Source: Official Lighthouse TypeScript definitions (types/lhr/audit-details.d.ts).
Citations:
- 1: https://github.com/GoogleChrome/lighthouse/blob/master/types/lhr/audit-details.d.ts
- 2: https://github.com/GoogleChrome/lighthouse/blob/main/docs/understanding-results.md
🏁 Script executed:
# Search for any references to Lighthouse audit types in the codebase itself
rg -i "screenshot-thumbnails|filmstrip" src/ --type tsRepository: freke/light-house-report
Length of output: 50
stripScreenshots doesn't strip screenshot-thumbnails (filmstrip frames).
Lighthouse's screenshot-thumbnails audit emits details.type === 'filmstrip', not 'screenshot', and contains base64-encoded JPEG thumbnail data. The current predicate only removes audits matching details.type === 'screenshot', so these frames—typically 8–10 thumbnails per page load—remain in every .lhr.json file written to runsDir, defeating much of the size reduction this helper is intended to provide.
Proposed fix
- if (a.details && a.details.type === 'screenshot') {
+ if (a.details && (a.details.type === 'screenshot' || a.details.type === 'filmstrip')) {
delete lhr.audits[key];
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lighthouse.ts` around lines 13 - 24, The stripScreenshots function
currently only removes audits whose details.type === 'screenshot' but misses
filmstrip thumbnails; update stripScreenshots to also remove audits whose
details.type === 'filmstrip' (and/or whose audit id/name corresponds to
screenshot-thumbnails) by checking aOR condition for details.type ===
'screenshot' || details.type === 'filmstrip' when iterating lhr.audits and
deleting matching entries (keep deleting lhr.fullPageScreenshot as-is and return
lhr).
Summary by CodeRabbit
New Features
Build & Release
Configuration
Chores