`formFactor: 'desktop'` changes which thresholds a metric is scored against and NOTHING else. Throttling and the user agent keep their defaults, which are mobile — so every desktop run was a desktop-sized window on a 4x-slowed CPU over slow 4G, announced as a moto-g. It read 86 where the same page reads 100. That gap was reported repeatedly as network latency to the host. It was not latency; it was this. Measured after the fix: desktop 100, LCP 0.4s against 2.2s. Nine more from the same review: runtimeError was never checked, so a page that failed to load reported "nothing failing" — the most reassuring possible output for a site that is down --json exited 0 before the gate, so the documented non-zero exit was true for a person reading the table and false for the only caller that could act on it scoreDisplayMode 'error' was filtered out while still nulling its category, so a category could read 0 with an empty findings list a missing metric serialised as 0, which a script reads as a perfect measurement `source` is a string on some audits and an object on others; String() on the object printed [object Object] the watchdog's surrender was permanent — the health branch sat below the check, so once it stopped trying it also stopped noticing, and a server fixed by hand left it asleep until the platform restarted a failed restart leaves no process at all, because startServer deletes before it starts. status null then reads as "somebody stopped it", which the watchdog leaves alone — one bad restart would have turned it off and taken the server with it, silently the skill claimed the panel uses this engine. Nothing but the CLI does and gave `bun run build:site` with no cwd, which only works from the website checkout — api/static-site.ts documents that and the skill did not Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { existsSync } from 'node:fs';
|
||
import { mkdir } from 'node:fs/promises';
|
||
import { homedir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
|
||
/**
|
||
* Lighthouse, run against the built site from the panel that builds it.
|
||
*
|
||
* ── Why this is here and not a CLI somebody runs ──
|
||
*
|
||
* Every performance decision on this site was made by reading a report run by hand in DevTools and
|
||
* typed back into a conversation. That loop is slow, and it is lossy in one particular way: nothing
|
||
* measures AFTER a build, so a regression is found whenever somebody next happens to look. Three real
|
||
* defects lived that way — a `sizes` string that overstated a column by 48px, font preloads that had
|
||
* gone missing entirely, an LCP image left `loading="lazy"` by a rename.
|
||
*
|
||
* ── The browser ──
|
||
*
|
||
* This machine has none. Ubuntu 24.04's apt `chromium` is a snap stub, and there is no Chrome repo.
|
||
* So the plugin fetches its own, once, through `@puppeteer/browsers` — a library call rather than a
|
||
* postinstall, because bun does not run postinstall for an untrusted package and a silent no-op there
|
||
* would surface as a failure at the first audit instead of at install time.
|
||
*
|
||
* An already-installed Chrome wins if there is one. Nothing is downloaded on a machine that has a
|
||
* browser already, and the download is ~150 MB the first time and free after that.
|
||
*/
|
||
|
||
/** Where the fetched browser lives. Beside the other caches, not in the plugin's own directory. */
|
||
const BROWSER_CACHE = join(homedir(), '.cache', 'officer', 'browsers');
|
||
|
||
/** What Lighthouse is asked for. The four scored categories; nothing here needs a trace viewer. */
|
||
const CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo'] as const;
|
||
|
||
export type FormFactor = 'mobile' | 'desktop';
|
||
|
||
/** `value` is null when the metric did not run — which is not the same as it being zero. */
|
||
export type AuditMetric = { id: string; label: string; value: number | null; display: string };
|
||
|
||
export type AuditFinding = {
|
||
id: string;
|
||
title: string;
|
||
/** 0–1, or null for an audit that is diagnostic rather than scored. */
|
||
score: number | null;
|
||
/** Bytes or milliseconds Lighthouse thinks this costs, when it says. */
|
||
savings: string | null;
|
||
/**
|
||
* WHICH resources, when the audit names any.
|
||
*
|
||
* "Improve image delivery — 32 KiB" is not actionable; the same finding with the URL of the image
|
||
* and the size it was served at is a one-line fix. Lighthouse carries them in `details.items` and
|
||
* the shape varies per audit, so this takes the two fields every table has and leaves the rest.
|
||
*/
|
||
items: { url: string; detail: string }[];
|
||
};
|
||
|
||
export type AuditResult = {
|
||
url: string;
|
||
formFactor: FormFactor;
|
||
scores: Record<string, number | null>;
|
||
metrics: AuditMetric[];
|
||
/** Everything that did not pass, worst first — the list to work down. */
|
||
findings: AuditFinding[];
|
||
finishedAt: string;
|
||
};
|
||
|
||
/**
|
||
* A Chrome to drive, downloading one if this machine has none.
|
||
*
|
||
* Resolved once per process and remembered: the check is a few stats, the download is minutes, and an
|
||
* audit of two form factors would otherwise ask twice.
|
||
*/
|
||
let resolving: Promise<string> | null = null;
|
||
|
||
export function chromePath(log: (line: string) => void = () => {}): Promise<string> {
|
||
if (resolving) return resolving;
|
||
|
||
resolving = (async () => {
|
||
/*
|
||
An installed browser wins, and it is looked for in three ways because one is not enough.
|
||
|
||
`CHROME_PATH` first: it is what chrome-launcher itself honours, so anybody who has already told
|
||
one tool where their browser is has told this one too.
|
||
|
||
Then chrome-launcher's own search, which knows every place a Chrome hides per platform. It
|
||
THROWS when it finds nothing rather than returning null — so an unguarded call does not fall
|
||
through to the download, it takes the whole audit down with `ChromePathNotSetError`. Caught.
|
||
|
||
Then the Chrome-family binaries by name, because the search above misses two that work perfectly:
|
||
Brave, which is Chromium underneath and is what this machine has, and a snap chromium. Same order
|
||
the iris plugin's wrapper uses, and for the same reason — the browser somebody already installed
|
||
is better than a second copy of one.
|
||
*/
|
||
const fromEnv = process.env.CHROME_PATH;
|
||
if (fromEnv && existsSync(fromEnv)) {
|
||
log(`using CHROME_PATH: ${fromEnv}`);
|
||
return fromEnv;
|
||
}
|
||
|
||
const { Launcher } = await import('chrome-launcher');
|
||
try {
|
||
const found = Launcher.getFirstInstallation();
|
||
if (found) {
|
||
log(`using the browser already installed: ${found}`);
|
||
return found;
|
||
}
|
||
} catch {
|
||
// Nothing where it looked. Not an error here — there are two more places to try.
|
||
}
|
||
|
||
for (const name of ['brave-browser', 'brave', 'chromium', 'chromium-browser', 'google-chrome', 'google-chrome-stable']) {
|
||
const which = Bun.which(name);
|
||
if (which) {
|
||
log(`using ${name}: ${which}`);
|
||
return which;
|
||
}
|
||
}
|
||
|
||
const browsers = await import('@puppeteer/browsers');
|
||
await mkdir(BROWSER_CACHE, { recursive: true });
|
||
|
||
const buildId = await browsers.resolveBuildId(
|
||
browsers.Browser.CHROMEHEADLESSSHELL,
|
||
browsers.detectBrowserPlatform()!,
|
||
'stable',
|
||
);
|
||
|
||
const existing = browsers.computeExecutablePath({
|
||
browser: browsers.Browser.CHROMEHEADLESSSHELL,
|
||
buildId,
|
||
cacheDir: BROWSER_CACHE,
|
||
});
|
||
if (existsSync(existing)) return existing;
|
||
|
||
log(`no browser on this machine — fetching Chrome ${buildId} (about 150 MB, once)`);
|
||
|
||
let lastPercent = -1;
|
||
const installed2 = await browsers.install({
|
||
browser: browsers.Browser.CHROMEHEADLESSSHELL,
|
||
buildId,
|
||
cacheDir: BROWSER_CACHE,
|
||
// A percentage per ten points rather than per chunk: this goes to a log somebody may be reading
|
||
// live, and a progress bar rendered one line per chunk is thousands of lines.
|
||
downloadProgressCallback: (downloaded, total) => {
|
||
const percent = Math.floor((downloaded / total) * 10) * 10;
|
||
if (percent === lastPercent) return;
|
||
lastPercent = percent;
|
||
log(` downloading Chrome… ${percent}%`);
|
||
},
|
||
});
|
||
|
||
log(`Chrome ready at ${installed2.executablePath}`);
|
||
return installed2.executablePath;
|
||
})();
|
||
|
||
// Not cached on failure: a download that fell over should be retried by the next caller rather than
|
||
// remembered as the answer forever.
|
||
resolving.catch(() => {
|
||
resolving = null;
|
||
});
|
||
|
||
return resolving;
|
||
}
|
||
|
||
/** Lighthouse's own numbers for a metric, in the shape a table wants. */
|
||
const METRIC_IDS = [
|
||
'first-contentful-paint',
|
||
'largest-contentful-paint',
|
||
'total-blocking-time',
|
||
'cumulative-layout-shift',
|
||
'speed-index',
|
||
] as const;
|
||
|
||
/**
|
||
* One audit.
|
||
*
|
||
* `formFactor` is the whole difference between the two runs somebody cares about: mobile throttles the
|
||
* CPU 4x and emulates a slow connection, desktop does neither, and a site can be 100 on one and 95 on
|
||
* the other for reasons that have nothing in common.
|
||
*/
|
||
export async function audit(
|
||
url: string,
|
||
formFactor: FormFactor,
|
||
log: (line: string) => void = () => {},
|
||
): Promise<AuditResult> {
|
||
const chrome = await chromePath(log);
|
||
const { launch } = await import('chrome-launcher');
|
||
const lighthouse = (await import('lighthouse')).default;
|
||
|
||
log(`${formFactor}: launching`);
|
||
const instance = await launch({
|
||
chromePath: chrome,
|
||
// `--headless=new` is the default for the headless shell; the rest is what a container wants.
|
||
chromeFlags: ['--headless', '--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'],
|
||
});
|
||
|
||
try {
|
||
log(`${formFactor}: auditing ${url}`);
|
||
const run = await lighthouse(url, {
|
||
port: instance.port,
|
||
output: 'json',
|
||
logLevel: 'error',
|
||
onlyCategories: [...CATEGORIES],
|
||
formFactor,
|
||
/*
|
||
── `formFactor` alone does NOT make a desktop audit ──
|
||
|
||
It changes which of Lighthouse's own thresholds a metric is scored against and nothing else.
|
||
The THROTTLING and the USER AGENT keep their defaults, which are mobile: a 4x CPU slowdown and
|
||
slow 4G, announced as a moto-g. So a `--desktop` run was a desktop-sized window crawling on an
|
||
emulated phone, and it read 86 where the same page read 100 in a real desktop Lighthouse.
|
||
|
||
That number was believed for a while and blamed on network latency. It was not latency; it was
|
||
this. All four have to agree or the run is about nothing.
|
||
|
||
The numbers are Lighthouse's own — `desktopDense4G` from its constants: 40 ms RTT, 10 Mbps, no
|
||
CPU slowdown. Copied rather than imported because the constants module is not part of its
|
||
public surface and pinning to it would break on an upgrade that moved the file.
|
||
*/
|
||
screenEmulation:
|
||
formFactor === 'desktop'
|
||
? { mobile: false, width: 1350, height: 940, deviceScaleFactor: 1, disabled: false }
|
||
: { mobile: true, width: 412, height: 823, deviceScaleFactor: 1.75, disabled: false },
|
||
throttling:
|
||
formFactor === 'desktop'
|
||
? {
|
||
rttMs: 40,
|
||
throughputKbps: 10 * 1024,
|
||
cpuSlowdownMultiplier: 1,
|
||
requestLatencyMs: 0,
|
||
downloadThroughputKbps: 0,
|
||
uploadThroughputKbps: 0,
|
||
}
|
||
: {
|
||
rttMs: 150,
|
||
throughputKbps: 1.6 * 1024,
|
||
cpuSlowdownMultiplier: 4,
|
||
requestLatencyMs: 150 * 3.75,
|
||
downloadThroughputKbps: 1.6 * 1024 * 0.9,
|
||
uploadThroughputKbps: 750 * 0.9,
|
||
},
|
||
emulatedUserAgent:
|
||
formFactor === 'desktop'
|
||
? 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' +
|
||
'Chrome/131.0.0.0 Safari/537.36'
|
||
: undefined,
|
||
throttlingMethod: 'simulate',
|
||
// Never. It ships to Sentry, and a build tool that phones home about this site is not something
|
||
// anybody asked for. Off by default in lighthouse too; said here so it stays off.
|
||
// biome-ignore lint/suspicious/noExplicitAny: the flags type is not exported
|
||
} as any);
|
||
|
||
if (!run?.lhr) throw new Error('lighthouse returned nothing');
|
||
|
||
/*
|
||
A run that could not load the page still returns an `lhr`.
|
||
|
||
Every category comes back null, every audit is filtered out, and the report reads "nothing
|
||
failing" — the most reassuring possible output for a site that is down. `runtimeError` is the one
|
||
field that says so.
|
||
*/
|
||
if (run.lhr.runtimeError?.code && run.lhr.runtimeError.code !== 'NO_ERROR') {
|
||
throw new Error(`${run.lhr.runtimeError.code}: ${run.lhr.runtimeError.message ?? 'the page did not load'}`);
|
||
}
|
||
const lhr = run.lhr;
|
||
|
||
const scores: Record<string, number | null> = {};
|
||
for (const key of CATEGORIES) {
|
||
const category = lhr.categories[key];
|
||
scores[key] = category?.score === null || category?.score === undefined ? null : Math.round(category.score * 100);
|
||
}
|
||
|
||
const metrics: AuditMetric[] = METRIC_IDS.map((id) => {
|
||
const found = lhr.audits[id];
|
||
return {
|
||
id,
|
||
label: found?.title ?? id,
|
||
// NULL, not 0. A metric that did not run is not a metric that scored perfectly, and a `--json`
|
||
// consumer reading 0 for Total Blocking Time would believe the best possible news.
|
||
value: typeof found?.numericValue === 'number' ? found.numericValue : null,
|
||
display: found?.displayValue ?? '—',
|
||
};
|
||
});
|
||
|
||
/*
|
||
Everything that did not pass, worst first.
|
||
|
||
`scoreDisplayMode` is what separates a real failure from a note: `informative` and `manual` are
|
||
diagnostics that never had a score, and listing them as failures is how a report becomes noise
|
||
nobody reads. Anything scored below 0.9 is a finding.
|
||
*/
|
||
const findings: AuditFinding[] = Object.values(lhr.audits)
|
||
.filter((one) => {
|
||
/*
|
||
`error` is a failure and is listed. It nulls the whole category score, so filtering it out
|
||
alongside the diagnostics meant a category could read 0 with an empty findings list — the
|
||
one case where "nothing failing" is exactly wrong.
|
||
*/
|
||
if (one.scoreDisplayMode === 'error') return true;
|
||
if (one.scoreDisplayMode === 'informative' || one.scoreDisplayMode === 'manual') return false;
|
||
if (one.scoreDisplayMode === 'notApplicable') return false;
|
||
return typeof one.score === 'number' && one.score < 0.9;
|
||
})
|
||
.sort((a, b) => (a.score ?? 0) - (b.score ?? 0))
|
||
.map((one) => {
|
||
/*
|
||
The rows, flattened to a URL and one line about it.
|
||
|
||
Every table-shaped audit carries `details.items`; what is IN an item differs — a byte saving,
|
||
a millisecond saving, a pair of dimensions, a node. So the numbers that exist are named and
|
||
the rest is dropped rather than guessed at, and an audit with no table simply has no rows.
|
||
*/
|
||
const details = one.details as { items?: Record<string, unknown>[] } | undefined;
|
||
const items = (details?.items ?? []).slice(0, 5).map((item) => {
|
||
const parts: string[] = [];
|
||
if (typeof item.wastedBytes === 'number') parts.push(`${Math.round(item.wastedBytes / 1024)} KiB wasted`);
|
||
if (typeof item.totalBytes === 'number') parts.push(`${Math.round(item.totalBytes / 1024)} KiB served`);
|
||
if (typeof item.wastedMs === 'number') parts.push(`${Math.round(item.wastedMs)} ms`);
|
||
const node = item.node as { nodeLabel?: string } | undefined;
|
||
if (node?.nodeLabel) parts.push(node.nodeLabel);
|
||
/*
|
||
`source` is a string on some audits and a `source-location` object on others —
|
||
`deprecations` and the insight audits are the ones that bite. `String()` on the object
|
||
prints `[object Object]`, which is worse than printing nothing.
|
||
*/
|
||
const source = item.source as { url?: unknown } | string | undefined;
|
||
const from =
|
||
typeof source === 'string' ? source
|
||
: source && typeof source.url === 'string' ? source.url
|
||
: '';
|
||
|
||
return {
|
||
url: typeof item.url === 'string' ? item.url : from,
|
||
detail: parts.join(', '),
|
||
};
|
||
});
|
||
|
||
return {
|
||
id: one.id,
|
||
title: one.title,
|
||
score: typeof one.score === 'number' ? one.score : null,
|
||
savings: one.displayValue ?? null,
|
||
items: items.filter((item) => item.url || item.detail),
|
||
};
|
||
});
|
||
|
||
return { url, formFactor, scores, metrics, findings, finishedAt: new Date().toISOString() };
|
||
} finally {
|
||
await instance.kill();
|
||
}
|
||
}
|