unlighthouse
Tested on Node 24 against the unlighthouse release that ships this Skill (requires Node >=22.18.0).
The package finds the URLs of a site and runs Lighthouse on each one in parallel Chrome instances.
It ships two binaries: unlighthouse opens a live dashboard, and unlighthouse-ci exits with a status code. Docs: https://unlighthouse.dev
Setup
npx unlighthouse-ci --site https://example.com --budget 80- Install
unlighthousefor both binaries.@unlighthouse/cliships onlyunlighthouse-ci. - Do not install
puppeteer.puppeteer-clusteralready depends on it. - Chrome: the package uses the system Chrome. If there is none, it downloads Chrome to
~/.unlighthouse. As root, it adds--no-sandboxitself. - Add
.unlighthouseto.gitignore. @unlighthouse/nuxt,@unlighthouse/vite, and@unlighthouse/webpackare deprecated. Run the CLI against the dev server URL instead.- The config file is
unlighthouse.config.tsin the current directory. A CLI flag you pass wins over the config file. A flag you leave out keeps the config value.
Pick the binary
unlighthouse-ciscans, writes reports, and exits. Exit code 1 means a budget failed, or no route was left to scan. Use it in scripts, CI, and Agent runs.unlighthousestarts a dashboard server (port 5678 by default), opens a browser, and keeps running after the scan. It never exits by itself.
Automatic behaviour
- URL discovery:
urlsif set, else robots.txt, then sitemap.xml, then a crawler that follows links from the home page. Robots.txtDisallowlines become excludes. --urlsorurlsturns off robots.txt, sitemap, crawler, and sampling. The paths are relative tosite.- A
sitewith a path, such ashttps://example.com/docs, turns off sitemap, robots.txt, and sampling. - A sitemap with 50 or more URLs turns off the crawler, except on
localhost. - Dynamic sampling scans at most 8 random URLs per route group, such as
/blog/*. Turn it off with--disable-dynamic-sampling. scanner.maxRoutes(200) stops the queue. It logs a warning and exits 0.- HTML inspection skips JavaScript. If the home page has no links, the crawler turns JavaScript on and retries. For an SPA, pass
--enable-javascript. - Device is mobile. Pass
--desktopfor desktop. - Throttling is simulated for a remote site and off for a local one (
localhost,127.0.0.1). An explicitscanner.throttleor--throttlewins. - Redirects: if
siteredirects to another host, such as http to https or apex to www, the scan uses the redirect target.
Common tasks
Per category budgets, reporters, and scope in one config:
import { defineUnlighthouseConfig } from 'unlighthouse/config'
export default defineUnlighthouseConfig({
site: 'https://staging.example.com',
scanner: {
exclude: ['/admin/**', '/api/**'],
include: ['/', /^\/blog\//, '/docs/**'], // keep '/' so the crawler can start
},
ci: {
budget: { 'performance': 70, 'accessibility': 95, 'best-practices': 90, 'seo': 90 },
reporter: 'jsonExpanded',
},
})- Budgets use 0 to 100. Report scores use 0 to 1. A budget of 80 fails a score of 0.79.
- Reporters:
json(default),jsonExpanded,csv,csvExpanded,lighthouseServer. The file is<outputPath>/ci-result.jsonorci-result.csv. lighthouseServerneeds--lhci-hostand--lhci-build-token.
Static HTML report: pass --build-static, or set ci.buildStatic: true. It writes index.html to the root of outputPath (.unlighthouse). Upload that folder. It deletes the per page lighthouse.json files.
Authentication: the authenticate hook gets the Puppeteer Page as its first argument. It runs once. Its cookies reach every scanned page, including the Lighthouse run, and only the hosts they belong to.
import { defineUnlighthouseConfig } from 'unlighthouse/config'
export default defineUnlighthouseConfig({
auth: { username: 'admin', password: process.env.AUTH_PASS! },
hooks: {
async authenticate(page) {
await page.goto('https://example.com/login')
await page.type('input[name="email"]', 'test@example.com')
await Promise.all([page.waitForNavigation(), page.click('button[type="submit"]')])
},
},
})Read scores in a hook: task-complete fires once per task, so check taskName. categories is an array of { key, id, title, score }.
import { defineUnlighthouseConfig } from 'unlighthouse/config'
export default defineUnlighthouseConfig({
hooks: {
'task-complete': (path, report, taskName) => {
if (taskName !== 'runLighthouseTask')
return
const performance = report.report?.categories.find(category => category.key === 'performance')
console.log(path, report.report?.score, performance?.score)
},
},
})Programmatic scan: register worker-finished before start(). Without a provider, reports go to .unlighthouse/<host>/<config hash>/.
import { createUnlighthouse } from 'unlighthouse'
const unlighthouse = await createUnlighthouse({ site: 'https://example.com', urls: ['/', '/about'] })
unlighthouse.hooks.hook('worker-finished', async () => {
console.log(unlighthouse.worker.reports().map(r => [r.route.path, r.report?.score]))
await unlighthouse.worker.cluster.close()
process.exit(0)
})
await unlighthouse.start()Traps
unlighthouse-ciclearsoutputPathbefore it scans, and refuses a folder it did not create.--output-path .exits 1 withRefusing to clear ... it is not an unlighthouse output folder. Point it at a new or empty folder. Unlighthouse marks its folders with a.unlighthouse-outputfile.- String patterns are route patterns, not regex.
--exclude-urls "/blog/.*"excludes nothing.*matches one segment and**any depth, so use/blog/**, or aRegExpin the config. - An
includelist that skips/stops a crawler scan. The crawler starts from/, so nothing is queued and both binaries exit 1 withNo routes left to scan. Add'/'toinclude, or use--urls. - A hook that throws stops
unlighthouse-ciwith exit code 1. Guard ontaskNameand optional fields. - Dynamic sampling is random. Two runs can scan different pages in one group. For stable CI results, pass
--urlsor turn sampling off.
Output
unlighthouse-ci:.unlighthouse/ci-result.jsonand.unlighthouse/reports/<path>/lighthouse.json.unlighthouse:.unlighthouse/<host>/<config hash>/. A config change starts a new cache folder.
Config
scanner.device('mobile'),scanner.samples(1, runs per page, averaged),scanner.throttle(on for remote sites, off for local ones).scanner.dynamicSampling(8),scanner.maxRoutes(200),scanner.crawler,scanner.sitemap,scanner.robotsTxt.puppeteerClusterOptions.maxConcurrency(half the CPU cores). Set 1 for stable performance scores.lighthouseOptionspasses through to Lighthouse.onlyAuditsreplacesonlyCategories.chrome.useSystem,chrome.downloadFallbackCacheDir.- All options: https://unlighthouse.dev/api-doc/config
Debug
--debuglogs the resolved config. A route thatincludeorexcludedrops logsSkipping route based on include / exclude rules.- "Failed to queue routes for scanning" means discovery found nothing. Check the
sitestatus and the robots.txtDisallowlines. - "No routes left to scan" means discovery found routes, and
include,exclude, or robots.txt rules skipped all of them.