Files
fofos-admin/api/lighthouse.ts
T
pastilhasandClaude Opus 5 74c6776c75 a desktop audit was not a desktop audit
`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>
2026-09-05 18:42:45 +01:00

351 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}