Scripting Lighthouse
Run Lighthouse from Node, sign in before it tests, script user flows with Puppeteer, and add your own audits. Recorded with Lighthouse 13.6.0 against a small account site and the course's store.
- 01Run it from codeCall Lighthouse from Node with flags, a config and the reports you want, and run it more than once.
- 02Test behind a loginSign in with Puppeteer, a header or kept storage, then check that the page tested is the page you asked for.
- 03Add your own checksWrite a gatherer, an audit and a config for a rule of your own, and know when a plugin can share it.
Lighthouse from your own code
Leaf and Kettle now has accounts. When Asha signs in, she sees her orders: mint tea, 120 grams, delivered. That page lives at /account, and it should be fast too.
In the last four lessons, you read one Lighthouse report at a time. You can make each one by hand: open DevTools, click a button, and wait. That works once. But the store changes every week, and nobody wants to click that button after every change. So you write a script: a small Node program that runs Lighthouse for you, the same way every time.
The course wrote that script, with Lighthouse 13.6.0, and pointed it at a small copy of the account site. It came back with a Performance score of 100. Then the course looked at which page Lighthouse had really tested:
// The script's first run on the account page, as the course recorded it.const run = { asked: "/account", tested: "/login", score: 1 }; console.log(`Asked for ${run.asked}.`);console.log(`Tested ${run.tested}.`);console.log(`Performance score: ${run.score * 100}.`);- Line 1 is a comment.
- Line 2 holds three things the run recorded: the page the script asked for, the page Lighthouse really tested, and the Performance score, from 0 to 1.
- Lines 4 to 6 print
Asked for /account.,Tested /login.andPerformance score: 100.
The script asked for the account page. Lighthouse tested the sign-in page instead, and gave it 100. Nothing crashed. The score looked perfect. Two questions follow:
- How did a test of the account page end up on the sign-in page?
- How do you get Lighthouse past the sign-in, and make it check the store's own rules too?
The answers share one idea. A script can control three moments of a run: before it starts, the run itself, and after it ends. Here is the whole plan at once:
Before the run, the script can sign in. During the run, it chooses the settings and adds checks of its own. After the run, it reads the result: which page was tested, the scores, and the reports to save.
In How Lighthouse works, Lighthouse was a home inspector who visits once. Now you book them for every Monday. You leave a note on the door: where the key is, which rooms to check, and two extra things you care about. Each week they follow the same note, so this week's report can be compared with last week's.
- In real life: A visit booked for every Monday
- In Lighthouse: A script that runs Lighthouse again and again
- In real life: Where the key is
- In Lighthouse: Signing in before the run
- In real life: Which rooms, and two extra things to check
- In Lighthouse: Flags, a config, and your own audits
- In real life: The same note every week
- In Lighthouse: The same settings every run, so results compare
- In real life: The report on your doormat
- In Lighthouse: The Lighthouse Result your script gets back
A script decides what happens before, during and after a Lighthouse run, and does it the same way every time. Then it checks the final URL. A score is for the page Lighthouse ended up on, which may not be the page you asked for.
The rest of this lesson builds the script one piece at a time:
- The Node API: call Lighthouse, get the reports back, and run it more than once.
- Logins: get past the sign-in page, and check that you did.
- User flows: measure the sign-in itself.
- Custom audits: add the store's own rules.
The Node API
An API is the set of functions a library lets you call. Lighthouse's Node API is mainly one function, lighthouse(). How Lighthouse works called it with two things. It can take three:
- The address of the page to test.
- Flags: settings for this one run, written as an object. Most are the same settings you can type on the command line, such as
onlyCategories. - A config, if you want one. It's a longer set of instructions, such as extra checks to run. The section on custom audits writes one.
A few flags work differently from Node. Lighthouse's docs list them in a table. Two of them matter here:
port: "Only specifies which port to use, Chrome is not launched for you." So your script starts Chrome itself. The port is the numbered door that lets Lighthouse drive that Chrome.configPath: "Ignored, pass the config in as the 3rd argument to lighthouse." On the command line you name a config file. From Node, you pass the config itself.
Here is a script that asks for two kinds of report from one run:
import { writeFileSync } from "node:fs";import lighthouse from "lighthouse";import * as chromeLauncher from "chrome-launcher"; const chrome = await chromeLauncher.launch({ chromeFlags: ["--headless=new"] });const flags = { port: chrome.port, onlyCategories: ["performance"], output: ["json", "html"] };const { lhr, report } = await lighthouse("http://127.0.0.1:8080/login", flags); writeFileSync("report.json", report[0]);writeFileSync("report.html", report[1]);console.log(lhr.categories.performance.score);await chrome.kill();- Lines 1 to 3 load three things: a function that writes files, Lighthouse, and
chrome-launcher, a small package that starts Chrome. - Line 5 starts Chrome without a window.
- Line 6 sets the flags.
portsays where that Chrome is.onlyCategoriesasks for Performance only.outputasks for two formats: JSON, for code to read, and HTML, for people. - Line 7 runs Lighthouse. It gives back two things.
lhris the Lighthouse Result: one big object with everything the run found.reportholds the reports as text. - Lines 9 and 10 save the two reports to files.
- Line 11 prints the Performance score, from 0 to 1. Line 12 closes Chrome.
With one format, report is one string. With two, the course's run gave back an array of 2 strings, in the order asked: the JSON first, then the HTML. Why it matters: lines 9 and 10 rely on that order. Swap the formats in line 6, and each file gets the other report.
Run it more than once
Remember the bathroom scale from The Lighthouse score. Step on it twice, and you can get two different readings. So you weigh yourself a few times and take the middle one. Lighthouse's scores wobble the same way, so a script should run it several times too.
Lighthouse's docs on variability put a number on it: "The median Lighthouse score of 5 runs is twice as stable as 1 run." The median is the middle value, once the values are sorted. The same page gives three tips:
- Run them one after another. The docs say, in capitals: "DO NOT collect multiple Lighthouse reports at the same time on the same machine." Runs at the same time compete for the same processor.
- Let Lighthouse pick the middle run. From Node, it has a helper called
computeMedianRun. Give it several results. It picks the run closest to the middle on two metrics: when the first text or image appeared, and when the page became fully interactive. - Or use Lighthouse CI. It's a separate tool. The docs call it "the simplest way to run Lighthouse multiple times and also get a median run".
Why it matters: a script that fails when the score drops below a line can fail on a day when nothing changed, if it reads one run. The docs advise basing such lines on "aggregate values like the median", "instead of single test results."
Testing pages behind a login
Back to the first run. Why did Lighthouse test /login? Lighthouse's docs explain: "Default runs of Lighthouse load a page as a 'new user', with no previous session or storage data."
Here is what that means on the account site. When Asha signs in, the server gives her browser a session cookie. A cookie is a small note the browser keeps for a site, and sends back with every request to it. This one says, in effect, "this is Asha, and she signed in".
The account site sends /account only to a browser that has signed in: one with that cookie, or with a token in a header. Any other browser gets a redirect: an answer that says "not here, go to /login". The script's Chrome had never signed in. So it had no cookie, the server sent it to the sign-in page, and Lighthouse tested the page it ended up on.
You book the inspector for flat 4B. They arrive, but the flat is locked, and nobody gave them a key. The concierge points them to the lobby. They inspect the lobby carefully, and their report says everything is in great shape. Every word is true. It's just about the lobby.
- In real life: Flat 4B, the place you booked
- In Lighthouse:
/account, the page you asked for:lhr.requestedUrl - In real life: No key
- In Lighthouse: No session cookie
- In real life: The concierge pointing to the lobby
- In Lighthouse: The server's redirect to
/login - In real life: The lobby, the place inspected
- In Lighthouse: The sign-in page:
lhr.finalDisplayedUrl - In real life: A glowing report
- In Lighthouse: A score of 100, for the sign-in page
Check which page was tested
The result says which page Lighthouse tested. Two fields of lhr hold it. requestedUrl is the address you gave. finalDisplayedUrl is the address of the page Lighthouse ended up testing. A script can compare them:
// Two fields of the first run's Lighthouse Result, shortened to their paths.const lhr = { requestedUrl: "/account", finalDisplayedUrl: "/login" }; if (lhr.finalDisplayedUrl !== lhr.requestedUrl) { console.log(`Careful: asked for ${lhr.requestedUrl}, but tested ${lhr.finalDisplayedUrl}.`);}- Line 2 holds the two fields from the first run, shortened to their paths.
- Line 4 checks whether they differ.
- They do, so line 5 prints
Careful: asked for /account, but tested /login.
Why it matters: put this check in every script. Without it, the sign-in page's score can stand in for your account page's, and nothing in the score tells you.
Ways past the sign-in
So the script has to sign in, or prove who it is some other way. Lighthouse's docs on authenticated pages list five ways:
- Script the sign-in with Puppeteer. The docs call this "the most flexible approach".
- Use the Lighthouse panel in DevTools. It "will never clear your cookies", so you can sign in first, then run it.
- Send a header with every request. A header is a line of extra information that travels with a request. In Node, the flag is
extraHeaders. - Open a Chrome with a debugging port, sign in by hand, and run Lighthouse on it with
--disable-storage-reset. - Reuse a Chrome user profile you prepared beforehand. A profile is the folder where Chrome keeps one person's cookies, history and bookmarks.
Start with the first. Puppeteer is a library that drives Chrome from code: it opens pages, types, and clicks. Here it signs in, in the same Chrome that Lighthouse will use:
const page = await browser.newPage();await page.goto("http://127.0.0.1:8080/login");await page.type("#user", "tea-lover");await Promise.all([page.waitForNavigation(), page.click("#sign-in")]); const { lhr } = await lighthouse("http://127.0.0.1:8080/account", { port: chrome.port });console.log(lhr.finalDisplayedUrl);- Line 1 opens a new tab.
browseris Puppeteer's connection to the Chrome the script started. - Line 2 goes to the sign-in page. Line 3 types a name into the form.
- Line 4 clicks Sign in, and waits for the page the click leads to. On the way, the server hands Chrome the session cookie.
- Line 6 runs Lighthouse on the account page, in the same Chrome. Line 7 prints the page it tested.
The course's run ended on /account: the page it asked for. The cookie was already in that Chrome, so it went with the request.
The course tried other ways too, and logged every request the account site received. Pick a way to see what the site saw, and which page Lighthouse tested:
/accountsigned out/loginsigned out/favicon.icosigned out
No session cookie, so the server sent /account on to /login. Lighthouse tested the sign-in page instead, and scored it 100.
Here are five of those runs side by side:
// Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score.const runs = { "fresh browser": { asked: "/account", tested: "/login", score: 1 }, "signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 }, "Authorization header": { asked: "/account", tested: "/account", score: 1 }, "token in localStorage": { asked: "/orders", tested: "/orders", score: 1 }, "token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 },}; for (const [way, { asked, tested, score }] of Object.entries(runs)) { console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`);}const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length;console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);- Lines 3 to 7 hold five recorded runs: the page each asked for, the page it tested, and its score.
- Lines 10 to 12 print one line per run. The first is
fresh browser: asked for /account, tested /login, score 100. - Line 13 counts the runs whose two pages match. Line 14 prints
tested the page asked for: 3 of 5.
Every run scored 100, including the two that tested a sign-in page. So the score can't tell you which page it's for. Only the final URL can.
The last run shows one more thing. The token page has a script that checks for the token, and sends the browser to /login-token if there's none. So a redirect doesn't have to come from the server. The page's own JavaScript can send Lighthouse somewhere else too.
Why the sign-in lasted
How Lighthouse works showed that Lighthouse resets the browser before each run. So why didn't the reset sign the script out? Because it clears only some kinds of storage:
The reset clears caches and service workers. It doesn't touch cookies, or localStorage: a store where a page's JavaScript can keep small pieces of text, like a token. It's like a cleaner who empties the bins before each visit, but leaves the keys on their hook.
The recording shows it. The session cookie went with the request in two runs, one straight after the other. The token in localStorage was found too.
One warning, about headers. You could send a Cookie header with extraHeaders, "but beware: it will override any other Cookies you expect to be there." For sign-ins that use cookies, the docs point to Puppeteer instead.
User flows with Puppeteer
Once the script was signed in, the account page scored 100. But what about signing in itself? Asha types her name, taps Sign in, and waits. That wait is a page load too, started by a click.
How Lighthouse works compared Lighthouse's modes to a shop's security camera: film the opening, film one customer, or take a photo. A user flow keeps several of those clips in one report, in order. Each clip is a step.
A step that loads a page is a navigation. Usually you give it an address. You can also give it a function that does something on the page, such as a click. The docs say this function "must perform an action that will trigger a navigation." They add: "Any interactions completed before the callback promise resolves will be captured by the navigation." In plain words: everything the function does, until it finishes, belongs to the step.
const page = await browser.newPage();const flow = await startFlow(page); await flow.navigate("http://127.0.0.1:8080/login", { name: "Sign-in page" });await flow.navigate(async () => { await page.type("#user", "tea-lover"); await page.click("#sign-in");}, { name: "Sign in" });await flow.snapshot({ name: "Signed in" }); writeFileSync("flow.html", await flow.generateReport());- Line 1 opens a tab. Line 2 starts a flow in it.
- Line 4 is the first step: a plain navigation to the sign-in page, named
Sign-in page. - Lines 5 to 8 are the second step, named
Sign in. Instead of an address, it gets a function. Line 6 types the name, and line 7 clicks Sign in. Lighthouse measures the page load that the click starts. - Line 9 is a snapshot: a look at the page as the sign-in left it, with no timing. It's named
Signed in. - Line 11 writes one report with all three steps.
The course's flow recorded 3 steps: Sign-in page, a navigation; Sign in, a navigation; Signed in, a snapshot.
The Sign in step started at /session and ended at /account. That's how the form works. It sends the name to /session. The server sets the session cookie there, and redirects to /account.
Lighthouse noticed the redirect, and added a warning to the step:
The page may not be loading as expected because your test URL (http://127.0.0.1:8080/session?user=tea-lover) was redirected to http://127.0.0.1:8080/account. Try testing the second URL directly.
That warning can mean a test missed its page, like the inspector in the lobby. Here, the redirect is the point: signing in sends you on to your account. Why it matters: read every warning, then decide whether it describes a mistake or the very thing you meant to measure.
Custom audits and plugins
Leaf and Kettle has two rules of its own. No built-in check knows these exact rules:
- The hero image has a size. The hero is the big photo at the top of the page. Its HTML must give it a width and a height. Without them, the page can jump when the photo arrives.
- JavaScript stays under 100 KiB. A KiB is 1,024 bytes. The limit counts the bytes the scripts take on the network.
To teach Lighthouse a rule, you add a check. Remember the inspector's clipboard from How Lighthouse works. During the visit, gatherers take readings and write them on the clipboard. Each reading is an artifact. Back at the office, audits check those readings, one check each. So a new rule needs up to three files:
- an audit: the new check;
- a gatherer, if the check needs a reading Lighthouse doesn't take already;
- a config: the instructions that add both to a run.
Lighthouse's recipe says a config "tells Lighthouse where to find the gatherer and audit files, when to run them, and how to incorporate their output into the Lighthouse report."
Before the visit, you hand the inspector a card. It says: "Do your usual checklist. Also measure the frame of the big photo in the hall. Add two checks of ours, and put them on a new page of the report, called House rules." The inspector still does everything they always do. The card only adds to it.
- In real life: "Do your usual checklist"
- In Lighthouse:
extends: "lighthouse:default" - In real life: "Also measure the photo frame in the hall"
- In Lighthouse:
artifacts: a new gatherer,HeroImage - In real life: "Two checks of ours"
- In Lighthouse:
audits:hero-has-sizeandscript-budget - In real life: "Put them on a new page, House rules"
- In Lighthouse:
categories: a new category, Store checks - In real life: "Only bring me that page this time"
- In Lighthouse:
--only-categories=store-checkson the command line
A new reading: the hero's size
The course's hero check takes its own reading, with a gatherer it wrote. The reading is the photo's width and height attributes:
import { Gatherer } from "lighthouse"; class HeroImage extends Gatherer { meta = { supportedModes: ["navigation", "snapshot"] }; async getArtifact(context) { return context.driver.executionContext.evaluate(() => { const hero = document.querySelector("#hero"); return hero && { width: hero.getAttribute("width"), height: hero.getAttribute("height") }; }, { args: [] }); }} export default HeroImage;- Line 1 loads Lighthouse's
Gathererclass. Line 3 makes a gatherer from it, calledHeroImage. - Line 4 says which modes it works in: navigation and snapshot.
- Line 6 is the method Lighthouse calls to take the reading.
- Line 7 runs a function inside the page. Line 8 finds the element with the id
hero. Line 9 returns its width and height attributes, ornullif there's no hero. - Line 10 closes that function. It passes no extra arguments into the page.
The audit reads that artifact and decides:
import { Audit } from "lighthouse"; class HeroSize extends Audit { static get meta() { return { id: "hero-has-size", title: "The hero image has a width and height", failureTitle: "The hero image has no width and height", description: "Without them, the page shifts when the photo arrives.", requiredArtifacts: ["HeroImage"], }; } static audit(artifacts) { const hero = artifacts.HeroImage; const sized = Boolean(hero && hero.width && hero.height); return { score: sized ? 1 : 0, displayValue: sized ? `${hero.width} by ${hero.height}` : "no size" }; }} export default HeroSize;- Lines 6 to 9 describe the audit: its id, a title for when it passes, a title for when it fails, and a description. The report shows them.
- Line 10 lists the artifacts it reads: just
HeroImage. - Line 15 takes the artifact. Line 16 asks: is there a hero, with both a width and a height?
- Line 17 returns the result. The score is 1 for a pass, or 0 for a fail.
displayValueis the short text the report shows next to it: the size, orno size.
A budget is a smoke alarm
The second rule is a budget: a limit you set on something you can measure, such as bytes or milliseconds. Lighthouse used to have budgets built in. Its changelog for version 12.0.0 lists "remove budgets" under breaking changes. An audit can do the same job.
Picture a smoke alarm with a dial for how much smoke sets it off. You set the dial once. Below that line, it stays quiet. Above it, it goes off, every time, however tidy the kitchen looks.
- In real life: The dial you set once
- In Lighthouse:
BUDGET_BYTES: 100 KiB of JavaScript - In real life: The smoke in the air
- In Lighthouse: The page's scripts, in bytes sent over the network
- In real life: The alarm going off
- In Lighthouse: A score of 0: "JavaScript is over budget"
- In real life: However tidy the kitchen looks
- In Lighthouse: Whatever the Performance score says
import { Audit, NetworkRecords } from "lighthouse"; const BUDGET_BYTES = 100 * 1024; class ScriptBudget extends Audit { static get meta() { return { id: "script-budget", title: "JavaScript fits the budget", failureTitle: "JavaScript is over budget", description: "The store's budget is 100 KiB of JavaScript, as transferred.", requiredArtifacts: ["DevtoolsLog"], }; } static async audit(artifacts, context) { const records = await NetworkRecords.request(artifacts.DevtoolsLog, context); const bytes = records.filter((record) => record.resourceType === "Script").reduce((sum, record) => sum + record.transferSize, 0); return { score: bytes <= BUDGET_BYTES ? 1 : 0, numericValue: bytes, numericUnit: "byte", displayValue: `${Math.round(bytes / 1024)} KiB of ${BUDGET_BYTES / 1024} KiB`, }; }} export default ScriptBudget;- Line 3 sets the line: 100 times 1,024 bytes, which is 100 KiB.
- Line 12 asks for the
DevtoolsLogartifact: the log of messages Chrome sent Lighthouse during the run. It holds every network request. Lighthouse already collects it, so this audit needs no gatherer of its own. - Line 17 turns that log into a list of network requests.
- Line 18 does two things. It keeps only the scripts. Then it adds up each one's
transferSize: the bytes it took on the network. - Line 20 passes at the budget or under it, and fails above it.
- Line 23 shows the total next to the budget, in KiB.
Put both on the card: the config
export default { extends: "lighthouse:default", artifacts: [{ id: "HeroImage", gatherer: "hero-gatherer" }], audits: ["hero-size-audit", "script-budget-audit"], categories: { "store-checks": { title: "Store checks", description: "The course's own checks for the store.", auditRefs: [ { id: "hero-has-size", weight: 1 }, { id: "script-budget", weight: 1 }, ], }, },};- Line 2 starts from Lighthouse's usual config: the usual checklist.
- Line 3 adds one artifact,
HeroImage, made by the filehero-gatherer. - Line 4 adds the two audit files.
- Lines 5 to 14 add a category, Store checks, with both audits in it. Each has a weight of 1, so they count the same.
A config can also hold settings. Lighthouse's configuration docs say they control things "such as CPU/network throttling and which audits should run." That's the throttling from Throttling and simulation.
The recipe adds one rule about extending: "all arrays will be concatenated and primitive values will override the defaults." In plain words: lists, like the audits, are added to Lighthouse's own lists. A single value, like one setting, replaces Lighthouse's value.
The course ran the config from the command line, in the folder with the four files:
npx lighthouse http://127.0.0.1:8080/ --config-path=custom-config.js --only-categories=store-checks--config-path names the config file. --only-categories=store-checks runs only the new category. The course ran it on two stores: the store from Performance insights, and the fast store from The Lighthouse score.
// Each store's two custom audits, from the command line's JSON: the score, and the text the report showed.const stores = { store: { "hero-has-size": [0, "no size"], "script-budget": [0, "152 KiB of 100 KiB"] }, "fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [0, "150 KiB of 100 KiB"] },}; for (const [name, audits] of Object.entries(stores)) { const [hero, budget] = Object.values(audits); const category = (hero[0] + budget[0]) / 2; console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`);}- Lines 3 and 4 are recorded from each run's JSON: each audit's score, and the text the report showed.
- Line 8 takes out the two audits. Line 9 averages their two scores.
- Line 10 prints one line per store. The first is
store: hero image no size, JavaScript 152 KiB of 100 KiB, Store checks 0. - The second is
fast store: hero image 360 by 270, JavaScript 150 KiB of 100 KiB, Store checks 50.
Look at the fast store. In The Lighthouse score, it scored 100 for Performance in all five runs. Its hero has a size, so the first check passes. But it still sends 150 KiB of JavaScript, because its app.js carries the whole catalog of 1,800 teas. So the alarm goes off.
Why it matters: a budget catches what a good score can hide. The score sums up five metrics. The budget watches the one thing you chose. A script can read this audit's score from lhr.audits, and stop with an error when it's 0. Then a change that adds too much JavaScript can't slip through.
Try the two rules yourself. Type a width and a height, and slide the JavaScript size. The playground uses the same rules as the audits:
hero-has-sizeno sizescript-budget152 KiB of 100 KiBhero-has-size: no size, so it failed. script-budget: 152 KiB of 100 KiB, so it failed. Both audits weigh 1, so Store checks is their average.
Real visits report the same kind of number through Resource Timing, so you can watch the budget for real users too:
Plugins: checks you can share
A plugin packages audits as an npm package, so other people can install them. The plugin handbook says plugins "are easily shared and have a stable API that won't change between minor version bumps". But they "are also more limited in scope than a custom Lighthouse configuration." A stable API means the parts of Lighthouse a plugin uses don't change in minor updates: the ones where only the middle number of the version goes up.
The handbook's table says what each can do. Read each row across: one ability, then whether a plugin and a custom config have it.
| What can it do? | A plugin | A custom config |
|---|---|---|
| Add your own audits | Yes, it can. | Yes, it can. |
| Add a category of its own | Yes, it can. | Yes, it can. |
| Be shared on npm for others to install | Yes, it can. | No, it can't. |
| Keep the same API through minor versions | Yes, it does. | No, it doesn't. |
| Gather new data from the page (artifacts) | No, it can't. | Yes, it can. |
| Change Lighthouse's own categories | No, it can't. | Yes, it can. |
| Change settings, such as throttling | No, it can't. | Yes, it can. |
So the course's hero check needs a config, because it uses a gatherer of its own. The budget reads only DevtoolsLog, which the handbook lists as available to plugins. So it could be shared as a plugin. A plugin is like a printed checklist any inspector can follow. Your own card can also ask for new readings.
Which approach?
Each job below needs one of three tools. Does it need steps in the browser, like typing and clicking? Then it's Puppeteer. Does one setting do it? Then it's a flag or a header. Does it need a check Lighthouse doesn't have? Then it's a custom audit.
- Sign in through a form before testing the account page
- Measure the page load that a click on Sign in starts
- Test a page that accepts a token in an Authorization header
- Keep a debug Chrome's storage between runs
- Fail when the page's JavaScript passes 100 KiB
- Check that the hero image has a width and height
Sort each job by the approach that fits it.
Common mix-ups
- "A 100 means Lighthouse tested my page." No. The fresh browser's 100 was for the sign-in page. Compare
finalDisplayedUrlwithrequestedUrl. - "Lighthouse signs you out before each run." No. Its reset clears caches and service workers. The session cookie and the localStorage token both survived it.
- "A Cookie header is the easy way in." No. It replaces the other cookies the page expects. The docs point to Puppeteer for sign-ins that use cookies.
- "One run is enough for a script." No. Scores wobble from run to run. The docs say the median of five runs is twice as stable as one.
- "A redirect warning always means a mistake." No. In the sign-in flow, the redirect to the account page was the point.
- "Plugins can do anything a config can." No. A plugin can't gather new data from the page or change settings.
- "Lighthouse still has budgets." No. Version 12.0.0 removed them. A custom audit can do the job.
Practice exercises
Answer first, then use the hints.
Suppose the Authorization header had carried a wrong token. The server would send /account on to /login, as it did for the fresh browser. What would the last line print?
// Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score.
const runs = {
"fresh browser": { asked: "/account", tested: "/login", score: 1 },
"signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 },
"Authorization header": { asked: "/account", tested: "/login", score: 1 },
"token in localStorage": { asked: "/orders", tested: "/orders", score: 1 },
"token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 },
};
for (const [way, { asked, tested, score }] of Object.entries(runs)) {
console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`);
}
const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length;
console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);tested the page asked for: 2 of 5. The header's run would still score 100, for the sign-in page. Only the final URL shows the mistake.
The store raises its budget to 160 KiB. Both stores now fit it. What does the last line print?
// Each store's two custom audits, from the command line's JSON: the score, and the text the report showed.
const stores = {
store: { "hero-has-size": [0, "no size"], "script-budget": [1, "152 KiB of 160 KiB"] },
"fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [1, "150 KiB of 160 KiB"] },
};
for (const [name, audits] of Object.entries(stores)) {
const [hero, budget] = Object.values(audits);
const category = (hero[0] + budget[0]) / 2;
console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`);
}fast store: hero image 360 by 270, JavaScript 150 KiB of 160 KiB, Store checks 100. Both audits pass, and the average of 1 and 1 is 1, shown as 100. The store still fails the hero check, so it gets 50.
In the logins playground, how many requests did the account site receive during the fresh browser's run?
3: /account, then /login after the redirect, then /favicon.ico, the small icon browsers ask for on their own.
In the Store checks playground, type 360 for the width and 270 for the height, and slide JavaScript to 90 KiB. What does Store checks show?
100. Both audits pass, and Store checks is their average.
On the command line, --quiet makes Lighthouse print less. From Node, the docs say the quiet flag is ignored. Which flag do they say to use instead?
logLevel. From Node, you set how much Lighthouse prints with logLevel, such as "error" for errors only. The course's recorder used that.
You want to share the budget audit as a plugin on npm. The plugin handbook says the package's name must start with something. What?
lighthouse-plugin-. The handbook says a plugin is "just a node module with a name that starts with" it. Its own example is called lighthouse-plugin-example.
Check your understanding
For each question, ask three things. Which page was tested? What kept the sign-in? What can each kind of extension do?
Question 1 of 7What does the last line of the logins example print?
Read the code, then predict// Each way of testing a page behind the login: the page asked for, the page Lighthouse tested, and the score. const runs = { "fresh browser": { asked: "/account", tested: "/login", score: 1 }, "signed in with Puppeteer": { asked: "/account", tested: "/account", score: 1 }, "Authorization header": { asked: "/account", tested: "/account", score: 1 }, "token in localStorage": { asked: "/orders", tested: "/orders", score: 1 }, "token page, fresh browser": { asked: "/orders", tested: "/login-token", score: 1 }, }; for (const [way, { asked, tested, score }] of Object.entries(runs)) { console.log(`${way}: asked for ${asked}, tested ${tested}, score ${Math.round(score * 100)}`); } const matched = Object.values(runs).filter(({ asked, tested }) => asked === tested).length; console.log(`tested the page asked for: ${matched} of ${Object.keys(runs).length}`);Choose an answer to see the explanation.
Question 2 of 7What does the first line of the custom category example print?
Read the code, then predict// Each store's two custom audits, from the command line's JSON: the score, and the text the report showed. const stores = { store: { "hero-has-size": [0, "no size"], "script-budget": [0, "152 KiB of 100 KiB"] }, "fast store": { "hero-has-size": [1, "360 by 270"], "script-budget": [0, "150 KiB of 100 KiB"] }, }; for (const [name, audits] of Object.entries(stores)) { const [hero, budget] = Object.values(audits); const category = (hero[0] + budget[0]) / 2; console.log(`${name}: hero image ${hero[1]}, JavaScript ${budget[1]}, Store checks ${category * 100}`); }Choose an answer to see the explanation.
Question 3 of 7Lighthouse gives your account page a 100, and the script never signed in. What should you check first?
Choose an answer to see the explanation.
Question 4 of 7Why might sending a Cookie header with extraHeaders cause trouble?
Choose an answer to see the explanation.
Question 5 of 7Your script will fail a check when the Performance score drops below 90. What should it compare with 90?
Choose an answer to see the explanation.
Question 6 of 7Your audit needs a gatherer of its own, to collect something from the page that Lighthouse doesn't. Plugin or custom config?
Choose an answer to see the explanation.
Question 7 of 7How do you measure the page load that a click on a Sign in button starts, in a user flow?
Choose an answer to see the explanation.
Key takeaways
- A script runs Lighthouse the same way every time. It controls what happens before, during and after the run.
lighthouse()takes an address, flags for this run, and an optional config. Ask for two outputs, and the reports come back in the order you asked.- Run it several times, one after another, and use the median, not one run.
- A fresh browser has no session. Sign in with Puppeteer in the same browser, or send a header. Lighthouse's reset keeps cookies and localStorage.
- Always compare the final URL with the one you asked for. A run sent to a sign-in page still scores.
- A user flow can measure the page load that a click starts, sign-ins included.
- A custom audit needs an audit and a config, plus a gatherer when it needs new data. Plugins share audits, but can't gather new data.
Remember the one-liner.
Script the visit, add your own checks, and confirm which page was tested.
Sources
The facts on this page come from these docs and source files:
- Using programmatically, Lighthouse docs.
- Score Variability, Lighthouse docs.
- median-run.js, computeMedianRun, Lighthouse 13.6.0 source.
- Running Lighthouse on Authenticated Pages, Lighthouse docs.
- User Data Directory, Chromium docs.
- constants.js, the default settings, Lighthouse 13.6.0 source.
- User Flows in Lighthouse, Lighthouse docs.
- Basic Custom Audit Recipe, Lighthouse docs.
- Changelog: 12.0.0 removes budgets, Lighthouse.
- Lighthouse Configuration, Lighthouse docs.
- Plugin Handbook, Lighthouse docs.
Coming next: WebPageTest basics, also in Measuring in the lab. Test from real locations and devices, then read the filmstrip, the video, and the waterfall.