lighthouse-audit-utils 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/dist/handle-audit-result.d.ts +24 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +196 -0
- package/dist/index.js.map +1 -0
- package/dist/log-recommendations.d.ts +25 -0
- package/dist/run-audit.d.ts +24 -0
- package/dist/thresholds.d.ts +32 -0
- package/dist/write-reports.d.ts +10 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blake Vandercar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# lighthouse-audit-utils
|
|
2
|
+
|
|
3
|
+
Everything you'd do with a finished Lighthouse run, in one call: write the
|
|
4
|
+
reports to disk, print the fix list to the terminal, and fail the run if a
|
|
5
|
+
category scored below its threshold. Each step can be configured or disabled.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install --save-dev lighthouse-audit-utils
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`lighthouse` is a peer dependency.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
`runAudit` audits a URL and handles the result:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { runAudit } from 'lighthouse-audit-utils'
|
|
19
|
+
|
|
20
|
+
await runAudit({
|
|
21
|
+
lighthouseArgs: {
|
|
22
|
+
url: 'https://example.com',
|
|
23
|
+
flags: { port, output: ['html', 'json'] },
|
|
24
|
+
},
|
|
25
|
+
reports: { directory: 'lighthouse', name: 'desktop' },
|
|
26
|
+
thresholds: { performance: 90 },
|
|
27
|
+
})
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`lighthouseArgs` is handed to `lighthouse()`, in the order it takes them:
|
|
31
|
+
|
|
32
|
+
| Option | Default | Description |
|
|
33
|
+
| -------- | ---------- | ----------------------------------------------------- |
|
|
34
|
+
| `url` | _required_ | The URL to audit |
|
|
35
|
+
| `flags` | _none_ | Settings for the run, e.g. the `port` Lighthouse uses |
|
|
36
|
+
| `config` | _none_ | Overrides the default config, e.g. `desktopConfig` |
|
|
37
|
+
|
|
38
|
+
Everything else it takes is passed straight to `handleAuditResult` below, which returns `{ result, failures }`.
|
|
39
|
+
|
|
40
|
+
## `handleAuditResult`
|
|
41
|
+
|
|
42
|
+
Handles a run you've already made yourself — this is what `runAudit` calls with the result from the actual `lighthouse()` audit:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import lighthouse from 'lighthouse'
|
|
46
|
+
import { handleAuditResult } from 'lighthouse-audit-utils'
|
|
47
|
+
|
|
48
|
+
const result = await lighthouse(url, { port, output: ['html', 'json'] })
|
|
49
|
+
if (!result) throw new Error('Lighthouse returned no result')
|
|
50
|
+
|
|
51
|
+
await handleAuditResult({
|
|
52
|
+
result,
|
|
53
|
+
reports: { directory: 'lighthouse', name: 'desktop' },
|
|
54
|
+
thresholds: { performance: 90 },
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The three steps run in that order — reports, recommendations, thresholds — so a
|
|
59
|
+
failing run still prints its fix list before throwing.
|
|
60
|
+
|
|
61
|
+
| Option | Default | Description |
|
|
62
|
+
| ----------------- | ---------- | ------------------------------------------------------------ |
|
|
63
|
+
| `result` | _required_ | The full `RunnerResult` from a Lighthouse run |
|
|
64
|
+
| `reports` | _none_ | Where to write the reports; omit to skip writing them |
|
|
65
|
+
| `recommendations` | _none_ | Recommendation logging options, or `false` to skip |
|
|
66
|
+
| `thresholds` | `100` | Minimum scores (0-100) — one number for all, or per category |
|
|
67
|
+
| `ignoreError` | `false` | Return the threshold failures rather than throwing on them |
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// just the log
|
|
72
|
+
await handleAuditResult({ result, ignoreError: true })
|
|
73
|
+
|
|
74
|
+
// just the thresholds
|
|
75
|
+
await handleAuditResult({ result, recommendations: false })
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `reports`
|
|
79
|
+
|
|
80
|
+
| Option | Default | Description |
|
|
81
|
+
| ----------- | ---------- | -------------------------------------------------------------- |
|
|
82
|
+
| `directory` | _required_ | Directory to write into; created if it doesn't exist |
|
|
83
|
+
| `name` | _required_ | Base filename, e.g. `desktop` → `desktop.html`, `desktop.json` |
|
|
84
|
+
|
|
85
|
+
Each report is written to `<directory>/<name>.<format>`, using the formats the
|
|
86
|
+
run's `output` flag asked for.
|
|
87
|
+
|
|
88
|
+
### `thresholds`
|
|
89
|
+
|
|
90
|
+
One number applies to every category; an object sets them individually. Any
|
|
91
|
+
category you leave out has to score 100, so the strict case is the default:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
await handleAuditResult({ result }) // every category must score 100
|
|
95
|
+
await handleAuditResult({ result, thresholds: 90 })
|
|
96
|
+
await handleAuditResult({ result, thresholds: { performance: 90 } })
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Only the categories present that are scored in the lighthouse report are checked.
|
|
100
|
+
|
|
101
|
+
### `ignoreError`
|
|
102
|
+
|
|
103
|
+
Returns the threshold failures instead of throwing an error, so you can decide what to do with
|
|
104
|
+
them. `undefined` when everything passed.
|
|
105
|
+
|
|
106
|
+
### `recommendations`
|
|
107
|
+
|
|
108
|
+
| Option | Default | Description |
|
|
109
|
+
| ---------------- | -------------- | -------------------------------------------------------------- |
|
|
110
|
+
| `label` | `reports.name` | Distinguishes runs of the same URL, e.g. `desktop`/`mobile` |
|
|
111
|
+
| `maxItems` | `5` | Rows shown per audit before collapsing to "…and N more" |
|
|
112
|
+
| `maxValueLength` | `120` | Max length of a single value before it's truncated with an "…" |
|
|
113
|
+
|
|
114
|
+
Pass `recommendations: false` to skip the log entirely.
|
|
115
|
+
|
|
116
|
+
#### Output
|
|
117
|
+
|
|
118
|
+
The log is the same fix list the report UI shows — failing audits, their
|
|
119
|
+
estimated savings, and the individual offending URLs/nodes — so a failing CI run
|
|
120
|
+
is actionable without downloading and opening the HTML report. Audits are
|
|
121
|
+
grouped by category and sorted by estimated savings, so the biggest wins come
|
|
122
|
+
first:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
───── Lighthouse recommendations: desktop — https://example.com/ ─────
|
|
126
|
+
|
|
127
|
+
Performance: 87
|
|
128
|
+
• Reduce unused JavaScript (Est savings of 1,010 KiB)
|
|
129
|
+
unused-javascript · score 50
|
|
130
|
+
- https://example.com/assets/vendor.css · Transfer Size: 24.1 KiB
|
|
131
|
+
- https://example.com/assets/fonts.css · Transfer Size: 7.3 KiB
|
|
132
|
+
- https://example.com/assets/dep-vendor-1.js · Transfer Size: 372 KiB · Est Savings: 343 KiB
|
|
133
|
+
- https://example.com/assets/dep-vendor-2.js · Transfer Size: 176 KiB · Est Savings: 149 KiB
|
|
134
|
+
- …and 2 more
|
|
135
|
+
• Properly size images (Potential savings of 96 KiB)
|
|
136
|
+
uses-responsive-images · score 62
|
|
137
|
+
- https://example.com/hero.png · Size: 142 KiB
|
|
138
|
+
- …and 3 more
|
|
139
|
+
• Render-blocking requests — est. savings: FCP 310 ms, LCP 310 ms
|
|
140
|
+
render-blocking-insight · score 50
|
|
141
|
+
- https://example.com/assets/index.css · Transfer Size: 12.4 KiB · Duration: 52 ms
|
|
142
|
+
|
|
143
|
+
Accessibility: 100 — nothing to fix
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Example: Playwright
|
|
147
|
+
|
|
148
|
+
Lighthouse navigates over the CDP port itself, so launch a persistent context on
|
|
149
|
+
that port and point `lighthouse` at it.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { desktopConfig } from 'lighthouse'
|
|
153
|
+
import { runAudit } from 'lighthouse-audit-utils'
|
|
154
|
+
|
|
155
|
+
await runAudit({
|
|
156
|
+
lighthouseArgs: {
|
|
157
|
+
url: page.url(),
|
|
158
|
+
flags: { port, output: ['html', 'json'] },
|
|
159
|
+
config: desktopConfig,
|
|
160
|
+
},
|
|
161
|
+
reports: { directory: testInfo.outputPath('lighthouse'), name: 'desktop' },
|
|
162
|
+
thresholds: { performance: 90 },
|
|
163
|
+
})
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Individual utilities
|
|
167
|
+
|
|
168
|
+
The three steps are also exported on their own, each taking the report first and
|
|
169
|
+
its options second:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
writeReports(result, { directory, name })
|
|
173
|
+
logRecommendations(lhr, { label, maxItems, maxValueLength })
|
|
174
|
+
checkAgainstThresholds(lhr, { thresholds, ignoreError })
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pnpm install
|
|
181
|
+
pnpm build # tsup → dist/ (types via tsc)
|
|
182
|
+
pnpm start # tsup watch
|
|
183
|
+
pnpm check # biome + tsc
|
|
184
|
+
pnpm format # biome check --fix
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
CI type-checks and builds against both supported peer majors, Lighthouse 12 and
|
|
188
|
+
13, on Node 22/24/26.
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RunnerResult } from 'lighthouse';
|
|
2
|
+
import { type FormattingArgs } from './log-recommendations';
|
|
3
|
+
import { type ThresholdsArgs } from './thresholds';
|
|
4
|
+
import { type WriteReportsArgs } from './write-reports';
|
|
5
|
+
export type HandleAuditResultArgs = {
|
|
6
|
+
/** The full `RunnerResult` from a Lighthouse run */
|
|
7
|
+
result: RunnerResult;
|
|
8
|
+
/** Where to write the reports. Omit to skip writing them. */
|
|
9
|
+
reports?: WriteReportsArgs;
|
|
10
|
+
/** Options for recommendations logging. Set to `false` to disable. */
|
|
11
|
+
recommendations?: (FormattingArgs & {
|
|
12
|
+
/** Distinguishes runs of the same URL in one log. Defaults to `reports.name`. */
|
|
13
|
+
label?: string;
|
|
14
|
+
}) | false;
|
|
15
|
+
} & ThresholdsArgs;
|
|
16
|
+
/**
|
|
17
|
+
* Everything you'd do with a finished Lighthouse run:
|
|
18
|
+
* 1. write the reports
|
|
19
|
+
* 2. log the recommendations
|
|
20
|
+
* 3. check the scores against the thresholds (goes last so reporting occurs before throwing).
|
|
21
|
+
*
|
|
22
|
+
* @returns the threshold failures, if `ignoreError` kept them from throwing
|
|
23
|
+
*/
|
|
24
|
+
export declare const handleAuditResult: ({ result, reports, thresholds, ignoreError, recommendations, }: HandleAuditResultArgs) => Promise<import("./thresholds").ThresholdFailure[] | undefined>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { type HandleAuditResultArgs, handleAuditResult, } from './handle-audit-result';
|
|
2
|
+
export { type FormattingArgs, logRecommendations, } from './log-recommendations';
|
|
3
|
+
export { type LighthouseArgs, runAudit } from './run-audit';
|
|
4
|
+
export { type Category, checkAgainstThresholds, type LighthouseThresholds, type ThresholdFailure, } from './thresholds';
|
|
5
|
+
export { writeReports } from './write-reports';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import lighthouse from 'lighthouse';
|
|
4
|
+
|
|
5
|
+
// lighthouse-audit-utils 1.0.0
|
|
6
|
+
|
|
7
|
+
// src/log-recommendations.ts
|
|
8
|
+
var resolveMetricSavings = (audit) => Object.entries(audit.metricSavings ?? {}).filter(([, ms]) => !!ms);
|
|
9
|
+
var maxMetricSavings = (audit) => Math.max(0, ...resolveMetricSavings(audit).map(([, ms]) => ms));
|
|
10
|
+
var metricValueType = (metric) => metric === "CLS" ? "numeric" : "ms";
|
|
11
|
+
var MEASURE_TYPES = [
|
|
12
|
+
"bytes",
|
|
13
|
+
"ms",
|
|
14
|
+
"timespanMs",
|
|
15
|
+
"numeric"
|
|
16
|
+
];
|
|
17
|
+
var formatValue = (value, valueType, maxValueLength) => {
|
|
18
|
+
if (value === void 0 || typeof value === "boolean") {
|
|
19
|
+
return "";
|
|
20
|
+
}
|
|
21
|
+
if (typeof value === "number") {
|
|
22
|
+
if (valueType === "bytes") {
|
|
23
|
+
const kib = value / 1024;
|
|
24
|
+
const rounded = kib >= 100 ? Math.round(kib) : Math.round(kib * 10) / 10;
|
|
25
|
+
return `${rounded} KiB`;
|
|
26
|
+
}
|
|
27
|
+
if (valueType === "ms" || valueType === "timespanMs") {
|
|
28
|
+
return `${Math.round(value)} ms`;
|
|
29
|
+
}
|
|
30
|
+
return String(Math.round(value * 100) / 100);
|
|
31
|
+
}
|
|
32
|
+
const text = (() => {
|
|
33
|
+
if (typeof value === "string") {
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (!("type" in value)) {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
switch (value.type) {
|
|
40
|
+
case "url":
|
|
41
|
+
return value.value;
|
|
42
|
+
case "link":
|
|
43
|
+
return value.url || value.text;
|
|
44
|
+
case "code":
|
|
45
|
+
return String(value.value);
|
|
46
|
+
case "node":
|
|
47
|
+
return value.nodeLabel ?? value.selector ?? value.snippet ?? "";
|
|
48
|
+
case "source-location":
|
|
49
|
+
return `${value.url}:${value.line}:${value.column}`;
|
|
50
|
+
default:
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
const singleLine = text.replace(/\s+/g, " ").trim();
|
|
55
|
+
return singleLine.length > maxValueLength ? `${singleLine.slice(0, maxValueLength)}\u2026` : singleLine;
|
|
56
|
+
};
|
|
57
|
+
var formatItems = (details, { maxItems, maxValueLength }) => {
|
|
58
|
+
if (details?.type !== "opportunity" && details?.type !== "table") {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
const [identity, ...measures] = details.headings.filter(({ key }) => !!key);
|
|
62
|
+
if (!identity) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const lines = details.items.slice(0, maxItems).map((item) => {
|
|
66
|
+
const columns = [
|
|
67
|
+
formatValue(item[identity.key ?? ""], identity.valueType, maxValueLength),
|
|
68
|
+
...measures.filter(({ valueType }) => MEASURE_TYPES.includes(valueType)).map(
|
|
69
|
+
(heading) => `${heading.label}: ${formatValue(item[heading.key ?? ""], heading.valueType, maxValueLength)}`
|
|
70
|
+
)
|
|
71
|
+
];
|
|
72
|
+
return ` - ${columns.filter(Boolean).join(" \xB7 ")}`;
|
|
73
|
+
});
|
|
74
|
+
const remaining = details.items.length - maxItems;
|
|
75
|
+
if (remaining > 0) {
|
|
76
|
+
lines.push(` - \u2026and ${remaining} more`);
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
};
|
|
80
|
+
var logRecommendations = (lhr, {
|
|
81
|
+
label,
|
|
82
|
+
maxItems = 5,
|
|
83
|
+
maxValueLength = 120
|
|
84
|
+
}) => {
|
|
85
|
+
const lines = [
|
|
86
|
+
"",
|
|
87
|
+
`\u2500\u2500\u2500\u2500\u2500 Lighthouse recommendations${label ? `: ${label}` : ""} \u2014 ${lhr.finalDisplayedUrl} \u2500\u2500\u2500\u2500\u2500`
|
|
88
|
+
];
|
|
89
|
+
for (const category of Object.values(lhr.categories)) {
|
|
90
|
+
const categoryScoreValue = category.score === null ? "N/A" : Math.round(category.score * 100);
|
|
91
|
+
const categoryLine = `${category.title}: ${categoryScoreValue}`;
|
|
92
|
+
const failing = category.auditRefs.map(({ id }) => lhr.audits[id]).filter((audit) => !!audit && audit.score !== null && audit.score < 1).sort(
|
|
93
|
+
(a, b) => maxMetricSavings(b) - maxMetricSavings(a) || (a.score ?? 0) - (b.score ?? 0)
|
|
94
|
+
);
|
|
95
|
+
if (!failing.length) {
|
|
96
|
+
lines.push("", `${categoryLine} \u2014 nothing to fix`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
lines.push("", categoryLine);
|
|
100
|
+
for (const audit of failing) {
|
|
101
|
+
const displayValue = audit.displayValue ? ` (${audit.displayValue})` : "";
|
|
102
|
+
const savingsValue = (() => {
|
|
103
|
+
if (audit.displayValue?.toLowerCase().includes("savings")) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
const overallSavingsBytes = audit.details?.type === "opportunity" ? audit.details.overallSavingsBytes : void 0;
|
|
107
|
+
const estSavings = [
|
|
108
|
+
...resolveMetricSavings(audit).map(
|
|
109
|
+
([metric, value]) => `${metric} ${formatValue(value, metricValueType(metric), maxValueLength)}`
|
|
110
|
+
),
|
|
111
|
+
formatValue(overallSavingsBytes, "bytes", maxValueLength)
|
|
112
|
+
].filter(Boolean);
|
|
113
|
+
return estSavings.length ? ` \u2014 est. savings: ${estSavings.join(", ")}` : "";
|
|
114
|
+
})();
|
|
115
|
+
const scoreValue = audit.score === null ? "N/A" : Math.round(audit.score * 100);
|
|
116
|
+
lines.push(` \u2022 ${audit.title}${displayValue}${savingsValue}`);
|
|
117
|
+
lines.push(` ${audit.id} \xB7 score ${scoreValue}`);
|
|
118
|
+
lines.push(...formatItems(audit.details, { maxItems, maxValueLength }));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
lines.push("");
|
|
122
|
+
console.log(lines.join("\n"));
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/thresholds.ts
|
|
126
|
+
var thresholdFailureMessage = (failures) => [
|
|
127
|
+
"Lighthouse thresholds not met:",
|
|
128
|
+
...failures.map(
|
|
129
|
+
({ category, minimum, score }) => `${category} scored ${score}, below the ${minimum} threshold`
|
|
130
|
+
)
|
|
131
|
+
].join("\n");
|
|
132
|
+
var checkAgainstThresholds = (lhr, { thresholds = 100, ignoreError }) => {
|
|
133
|
+
const isFlat = typeof thresholds === "number";
|
|
134
|
+
const defaultMinimum = isFlat ? thresholds : 100;
|
|
135
|
+
const minimums = isFlat ? {} : thresholds ?? {};
|
|
136
|
+
const failures = Object.values(lhr.categories).filter((c) => c.score !== null).map(({ id, score }) => ({
|
|
137
|
+
category: id,
|
|
138
|
+
minimum: minimums[id] ?? defaultMinimum,
|
|
139
|
+
score: score * 100
|
|
140
|
+
})).filter(({ score, minimum }) => score < minimum);
|
|
141
|
+
if (!failures.length) {
|
|
142
|
+
return void 0;
|
|
143
|
+
}
|
|
144
|
+
if (!ignoreError) {
|
|
145
|
+
throw new Error(thresholdFailureMessage(failures));
|
|
146
|
+
}
|
|
147
|
+
return failures;
|
|
148
|
+
};
|
|
149
|
+
var writeReports = async (result, { directory, name }) => {
|
|
150
|
+
const formats = [result.lhr.configSettings.output].flat();
|
|
151
|
+
await mkdir(directory, { recursive: true });
|
|
152
|
+
await Promise.all(
|
|
153
|
+
[result.report].flat().map(
|
|
154
|
+
(report, i) => writeFile(path.join(directory, `${name}.${formats[i]}`), report)
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// src/handle-audit-result.ts
|
|
160
|
+
var handleAuditResult = async ({
|
|
161
|
+
result,
|
|
162
|
+
reports,
|
|
163
|
+
thresholds,
|
|
164
|
+
ignoreError,
|
|
165
|
+
recommendations
|
|
166
|
+
}) => {
|
|
167
|
+
if (reports) {
|
|
168
|
+
await writeReports(result, reports);
|
|
169
|
+
}
|
|
170
|
+
if (recommendations !== false) {
|
|
171
|
+
logRecommendations(result.lhr, {
|
|
172
|
+
label: reports?.name,
|
|
173
|
+
...recommendations
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const failures = checkAgainstThresholds(result.lhr, {
|
|
177
|
+
thresholds,
|
|
178
|
+
ignoreError
|
|
179
|
+
});
|
|
180
|
+
return failures;
|
|
181
|
+
};
|
|
182
|
+
var runAudit = async ({
|
|
183
|
+
lighthouseArgs: { url, flags, config },
|
|
184
|
+
...handleArgs
|
|
185
|
+
}) => {
|
|
186
|
+
const result = await lighthouse(url, flags, config);
|
|
187
|
+
if (!result) {
|
|
188
|
+
throw new Error(`Lighthouse returned no result for ${url}`);
|
|
189
|
+
}
|
|
190
|
+
const failures = await handleAuditResult({ result, ...handleArgs });
|
|
191
|
+
return { result, failures };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export { checkAgainstThresholds, handleAuditResult, logRecommendations, runAudit, writeReports };
|
|
195
|
+
//# sourceMappingURL=index.js.map
|
|
196
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/log-recommendations.ts","../src/thresholds.ts","../src/write-reports.ts","../src/handle-audit-result.ts","../src/run-audit.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["import type { RunnerResult } from 'lighthouse'\nimport type Details from 'lighthouse/types/lhr/audit-details'\n\ntype Lhr = RunnerResult['lhr']\ntype Audit = Lhr['audits'][string]\ntype AuditDetails = NonNullable<Audit['details']>\n\n/** resolves to values that are only non-zero numbers */\nconst resolveMetricSavings = (audit: Audit) =>\n Object.entries(audit.metricSavings ?? {}).filter(([, ms]) => !!ms) as [\n string,\n number,\n ][]\n\nconst maxMetricSavings = (audit: Audit) =>\n Math.max(0, ...resolveMetricSavings(audit).map(([, ms]) => ms))\n\n/** CLS savings are a unitless shift score; every other metric is milliseconds. */\nconst metricValueType = (metric: string): Details.ItemValueType =>\n metric === 'CLS' ? 'numeric' : 'ms'\n\n/** Columns worth repeating alongside an item's identity (URL, node, ...). */\nconst MEASURE_TYPES: Details.ItemValueType[] = [\n 'bytes',\n 'ms',\n 'timespanMs',\n 'numeric',\n]\n\nexport type FormattingArgs = {\n /**\n * Rows shown per audit before collapsing to \"…and N more\"\n * @default 5\n */\n maxItems?: number\n /**\n * Max length of a single value before it's truncated with an \"…\"\n * @default 120\n */\n maxValueLength?: number\n}\n\nconst formatValue = (\n value: Details.ItemValue | undefined,\n valueType: Details.ItemValueType,\n maxValueLength: number\n): string => {\n if (value === undefined || typeof value === 'boolean') {\n return ''\n }\n\n if (typeof value === 'number') {\n if (valueType === 'bytes') {\n // Keep a decimal on small values so sub-KiB assets don't read as \"0 KiB\".\n const kib = value / 1024\n const rounded = kib >= 100 ? Math.round(kib) : Math.round(kib * 10) / 10\n return `${rounded} KiB`\n }\n if (valueType === 'ms' || valueType === 'timespanMs') {\n return `${Math.round(value)} ms`\n }\n return String(Math.round(value * 100) / 100)\n }\n\n // `IcuMessage` is the only object value without a `type` tag.\n const text = (() => {\n if (typeof value === 'string') {\n return value\n }\n if (!('type' in value)) {\n return ''\n }\n switch (value.type) {\n case 'url':\n return value.value\n case 'link':\n return value.url || value.text\n case 'code':\n return String(value.value)\n case 'node':\n return value.nodeLabel ?? value.selector ?? value.snippet ?? ''\n case 'source-location':\n return `${value.url}:${value.line}:${value.column}`\n default:\n return ''\n }\n })()\n\n // Node labels and snippets carry the element's own line breaks — flatten them\n // so one item stays on one line.\n const singleLine = text.replace(/\\s+/g, ' ').trim()\n\n return singleLine.length > maxValueLength\n ? `${singleLine.slice(0, maxValueLength)}…`\n : singleLine\n}\n\n/**\n * The individual offenders the report UI lists under an audit — the specific\n * render-blocking scripts, oversized images, failing DOM nodes, and so on.\n */\nconst formatItems = (\n details: AuditDetails | undefined,\n { maxItems, maxValueLength }: Required<FormattingArgs>\n): string[] => {\n if (details?.type !== 'opportunity' && details?.type !== 'table') {\n return []\n }\n\n const [identity, ...measures] = details.headings.filter(({ key }) => !!key)\n if (!identity) {\n return []\n }\n\n const lines = details.items.slice(0, maxItems).map((item) => {\n const columns = [\n formatValue(item[identity.key ?? ''], identity.valueType, maxValueLength),\n ...measures\n .filter(({ valueType }) => MEASURE_TYPES.includes(valueType))\n .map(\n (heading) =>\n `${heading.label}: ${formatValue(item[heading.key ?? ''], heading.valueType, maxValueLength)}`\n ),\n ]\n return ` - ${columns.filter(Boolean).join(' · ')}`\n })\n\n const remaining = details.items.length - maxItems\n if (remaining > 0) {\n lines.push(` - …and ${remaining} more`)\n }\n return lines\n}\n\n/**\n * Print the same fix list the Lighthouse report UI shows, so a failing run is\n * actionable from the terminal/CI log without opening the HTML report.\n */\nexport const logRecommendations = (\n /** The Lighthouse result object */\n lhr: Lhr,\n {\n label,\n maxItems = 5,\n maxValueLength = 120,\n }: {\n /** Distinguishes runs of the same URL in one log (e.g. `desktop`/`mobile`). */\n label?: string\n } & FormattingArgs\n) => {\n const lines = [\n '',\n `───── Lighthouse recommendations${label ? `: ${label}` : ''} — ${lhr.finalDisplayedUrl} ─────`,\n ]\n\n for (const category of Object.values(lhr.categories)) {\n const categoryScoreValue =\n category.score === null ? 'N/A' : Math.round(category.score * 100)\n const categoryLine = `${category.title}: ${categoryScoreValue}`\n\n const failing = category.auditRefs\n .map(({ id }) => lhr.audits[id])\n .filter((audit) => !!audit && audit.score !== null && audit.score < 1)\n .sort(\n (a, b) =>\n maxMetricSavings(b) - maxMetricSavings(a) ||\n (a.score ?? 0) - (b.score ?? 0)\n )\n\n if (!failing.length) {\n lines.push('', `${categoryLine} — nothing to fix`)\n continue\n }\n\n lines.push('', categoryLine)\n\n for (const audit of failing) {\n const displayValue = audit.displayValue ? ` (${audit.displayValue})` : ''\n\n const savingsValue: string = (() => {\n // Most opportunities already spell out their savings in `displayValue`.\n if (audit.displayValue?.toLowerCase().includes('savings')) {\n return ''\n }\n\n const overallSavingsBytes =\n audit.details?.type === 'opportunity'\n ? audit.details.overallSavingsBytes\n : undefined\n const estSavings = [\n ...resolveMetricSavings(audit).map(\n ([metric, value]) =>\n `${metric} ${formatValue(value, metricValueType(metric), maxValueLength)}`\n ),\n formatValue(overallSavingsBytes, 'bytes', maxValueLength),\n ].filter(Boolean)\n\n return estSavings.length\n ? ` — est. savings: ${estSavings.join(', ')}`\n : ''\n })()\n\n const scoreValue =\n audit.score === null ? 'N/A' : Math.round(audit.score * 100)\n\n lines.push(` • ${audit.title}${displayValue}${savingsValue}`)\n lines.push(` ${audit.id} · score ${scoreValue}`)\n lines.push(...formatItems(audit.details, { maxItems, maxValueLength }))\n }\n }\n\n lines.push('')\n console.log(lines.join('\\n'))\n}\n","import type { RunnerResult } from 'lighthouse'\n\ntype Lhr = RunnerResult['lhr']\ntype CategoryResult = Lhr['categories'][string]\n\nexport type Category =\n | 'performance'\n | 'accessibility'\n | 'best-practices'\n | 'seo'\n\n/** Per-category minimums, or one number applied to every category. */\nexport type LighthouseThresholds = number | Partial<Record<Category, number>>\n\n/** A category that scored below the minimum it was given. */\nexport type ThresholdFailure = {\n /** The category's id as the run reported it — usually a `Category`. */\n category: string\n /** The minimum this category was checked against (0-100). */\n minimum: number\n /** What it actually scored (0-100). */\n score: number\n}\n\nconst thresholdFailureMessage = (failures: ThresholdFailure[]) =>\n [\n 'Lighthouse thresholds not met:',\n ...failures.map(\n ({ category, minimum, score }) =>\n `${category} scored ${score}, below the ${minimum} threshold`\n ),\n ].join('\\n')\n\nexport type ThresholdsArgs = {\n /**\n * Minimum category scores (0-100), either per category or one number for all\n * of them. If per category, any category omitted must score 100.\n */\n thresholds?: LighthouseThresholds\n /** Return the failures rather than throwing them. */\n ignoreError?: boolean\n}\n\n/**\n * Checks every category the run scored, throwing an error describing the ones\n * that fell short. Pass `ignoreError` to get those shortfalls back instead, so\n * the caller can log the recommendations before failing.\n */\nexport const checkAgainstThresholds = (\n /** The Lighthouse result object */\n lhr: Lhr,\n { thresholds = 100, ignoreError }: ThresholdsArgs\n): ThresholdFailure[] | undefined => {\n const isFlat = typeof thresholds === 'number'\n const defaultMinimum = isFlat ? thresholds : 100\n const minimums: Record<string, number | undefined> = isFlat\n ? {}\n : (thresholds ?? {})\n\n const failures = Object.values(lhr.categories)\n .filter((c): c is CategoryResult & { score: number } => c.score !== null)\n .map(({ id, score }) => ({\n category: id,\n minimum: minimums[id] ?? defaultMinimum,\n score: score * 100,\n }))\n .filter(({ score, minimum }) => score < minimum)\n\n if (!failures.length) {\n return undefined\n }\n\n if (!ignoreError) {\n throw new Error(thresholdFailureMessage(failures))\n }\n\n return failures\n}\n","import { mkdir, writeFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport type { RunnerResult } from 'lighthouse'\n\nexport type WriteReportsArgs = { directory: string; name: string }\n\n/**\n * Writes each report to `<directory>/<name>.<format>`, using the formats the\n * run's `output` flag asked for.\n */\nexport const writeReports = async (\n result: RunnerResult,\n { directory, name }: WriteReportsArgs\n) => {\n const formats = [result.lhr.configSettings.output].flat()\n\n await mkdir(directory, { recursive: true })\n await Promise.all(\n [result.report]\n .flat()\n .map((report, i) =>\n writeFile(path.join(directory, `${name}.${formats[i]}`), report)\n )\n )\n}\n","import type { RunnerResult } from 'lighthouse'\n\nimport { type FormattingArgs, logRecommendations } from './log-recommendations'\nimport { checkAgainstThresholds, type ThresholdsArgs } from './thresholds'\nimport { type WriteReportsArgs, writeReports } from './write-reports'\n\nexport type HandleAuditResultArgs = {\n /** The full `RunnerResult` from a Lighthouse run */\n result: RunnerResult\n /** Where to write the reports. Omit to skip writing them. */\n reports?: WriteReportsArgs\n /** Options for recommendations logging. Set to `false` to disable. */\n recommendations?:\n | (FormattingArgs & {\n /** Distinguishes runs of the same URL in one log. Defaults to `reports.name`. */\n label?: string\n })\n | false\n} & ThresholdsArgs\n\n/**\n * Everything you'd do with a finished Lighthouse run:\n * 1. write the reports\n * 2. log the recommendations\n * 3. check the scores against the thresholds (goes last so reporting occurs before throwing).\n *\n * @returns the threshold failures, if `ignoreError` kept them from throwing\n */\nexport const handleAuditResult = async ({\n result,\n reports,\n thresholds,\n ignoreError,\n recommendations,\n}: HandleAuditResultArgs) => {\n if (reports) {\n await writeReports(result, reports)\n }\n\n if (recommendations !== false) {\n logRecommendations(result.lhr, {\n label: reports?.name,\n ...recommendations,\n })\n }\n\n const failures = checkAgainstThresholds(result.lhr, {\n thresholds,\n ignoreError,\n })\n\n return failures\n}\n","import lighthouse, { type Config, type Flags } from 'lighthouse'\n\nimport {\n type HandleAuditResultArgs,\n handleAuditResult,\n} from './handle-audit-result'\n\n/** What `lighthouse()` itself takes, in the order it takes it. */\nexport type LighthouseArgs = {\n /** The URL to audit */\n url: string\n /** Settings for the run, e.g. the `port` Lighthouse connects to */\n flags?: Flags\n /** Overrides the default config, e.g. `desktopConfig` */\n config?: Config\n}\n\n/**\n * Audits a URL and hands the result straight to {@link handleAuditResult}.\n *\n * @returns the run's result, plus the threshold failures if `ignoreError` kept\n * them from throwing\n */\nexport const runAudit = async ({\n lighthouseArgs: { url, flags, config },\n ...handleArgs\n}: {\n /** Passed through to `lighthouse()` */\n lighthouseArgs: LighthouseArgs\n} & Omit<HandleAuditResultArgs, 'result'>) => {\n const result = await lighthouse(url, flags, config)\n if (!result) {\n throw new Error(`Lighthouse returned no result for ${url}`)\n }\n\n const failures = await handleAuditResult({ result, ...handleArgs })\n\n return { result, failures }\n}\n"]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RunnerResult } from 'lighthouse';
|
|
2
|
+
type Lhr = RunnerResult['lhr'];
|
|
3
|
+
export type FormattingArgs = {
|
|
4
|
+
/**
|
|
5
|
+
* Rows shown per audit before collapsing to "…and N more"
|
|
6
|
+
* @default 5
|
|
7
|
+
*/
|
|
8
|
+
maxItems?: number;
|
|
9
|
+
/**
|
|
10
|
+
* Max length of a single value before it's truncated with an "…"
|
|
11
|
+
* @default 120
|
|
12
|
+
*/
|
|
13
|
+
maxValueLength?: number;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Print the same fix list the Lighthouse report UI shows, so a failing run is
|
|
17
|
+
* actionable from the terminal/CI log without opening the HTML report.
|
|
18
|
+
*/
|
|
19
|
+
export declare const logRecommendations: (
|
|
20
|
+
/** The Lighthouse result object */
|
|
21
|
+
lhr: Lhr, { label, maxItems, maxValueLength, }: {
|
|
22
|
+
/** Distinguishes runs of the same URL in one log (e.g. `desktop`/`mobile`). */
|
|
23
|
+
label?: string;
|
|
24
|
+
} & FormattingArgs) => void;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Config, type Flags } from 'lighthouse';
|
|
2
|
+
import { type HandleAuditResultArgs } from './handle-audit-result';
|
|
3
|
+
/** What `lighthouse()` itself takes, in the order it takes it. */
|
|
4
|
+
export type LighthouseArgs = {
|
|
5
|
+
/** The URL to audit */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Settings for the run, e.g. the `port` Lighthouse connects to */
|
|
8
|
+
flags?: Flags;
|
|
9
|
+
/** Overrides the default config, e.g. `desktopConfig` */
|
|
10
|
+
config?: Config;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Audits a URL and hands the result straight to {@link handleAuditResult}.
|
|
14
|
+
*
|
|
15
|
+
* @returns the run's result, plus the threshold failures if `ignoreError` kept
|
|
16
|
+
* them from throwing
|
|
17
|
+
*/
|
|
18
|
+
export declare const runAudit: ({ lighthouseArgs: { url, flags, config }, ...handleArgs }: {
|
|
19
|
+
/** Passed through to `lighthouse()` */
|
|
20
|
+
lighthouseArgs: LighthouseArgs;
|
|
21
|
+
} & Omit<HandleAuditResultArgs, 'result'>) => Promise<{
|
|
22
|
+
result: import("lighthouse").RunnerResult;
|
|
23
|
+
failures: import("./thresholds").ThresholdFailure[] | undefined;
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RunnerResult } from 'lighthouse';
|
|
2
|
+
type Lhr = RunnerResult['lhr'];
|
|
3
|
+
export type Category = 'performance' | 'accessibility' | 'best-practices' | 'seo';
|
|
4
|
+
/** Per-category minimums, or one number applied to every category. */
|
|
5
|
+
export type LighthouseThresholds = number | Partial<Record<Category, number>>;
|
|
6
|
+
/** A category that scored below the minimum it was given. */
|
|
7
|
+
export type ThresholdFailure = {
|
|
8
|
+
/** The category's id as the run reported it — usually a `Category`. */
|
|
9
|
+
category: string;
|
|
10
|
+
/** The minimum this category was checked against (0-100). */
|
|
11
|
+
minimum: number;
|
|
12
|
+
/** What it actually scored (0-100). */
|
|
13
|
+
score: number;
|
|
14
|
+
};
|
|
15
|
+
export type ThresholdsArgs = {
|
|
16
|
+
/**
|
|
17
|
+
* Minimum category scores (0-100), either per category or one number for all
|
|
18
|
+
* of them. If per category, any category omitted must score 100.
|
|
19
|
+
*/
|
|
20
|
+
thresholds?: LighthouseThresholds;
|
|
21
|
+
/** Return the failures rather than throwing them. */
|
|
22
|
+
ignoreError?: boolean;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Checks every category the run scored, throwing an error describing the ones
|
|
26
|
+
* that fell short. Pass `ignoreError` to get those shortfalls back instead, so
|
|
27
|
+
* the caller can log the recommendations before failing.
|
|
28
|
+
*/
|
|
29
|
+
export declare const checkAgainstThresholds: (
|
|
30
|
+
/** The Lighthouse result object */
|
|
31
|
+
lhr: Lhr, { thresholds, ignoreError }: ThresholdsArgs) => ThresholdFailure[] | undefined;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RunnerResult } from 'lighthouse';
|
|
2
|
+
export type WriteReportsArgs = {
|
|
3
|
+
directory: string;
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Writes each report to `<directory>/<name>.<format>`, using the formats the
|
|
8
|
+
* run's `output` flag asked for.
|
|
9
|
+
*/
|
|
10
|
+
export declare const writeReports: (result: RunnerResult, { directory, name }: WriteReportsArgs) => Promise<void>;
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lighthouse-audit-utils",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Small utilities for driving Lighthouse audits yourself: check scores against thresholds, write reports to disk, and log the fix list to the terminal.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"lighthouse",
|
|
7
|
+
"playwright",
|
|
8
|
+
"performance",
|
|
9
|
+
"ci",
|
|
10
|
+
"terminal"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/bvandrc/lighthouse-audit-utils"
|
|
15
|
+
},
|
|
16
|
+
"author": {
|
|
17
|
+
"name": "Blake Vandercar",
|
|
18
|
+
"email": "bvandercar@outlook.com"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"source": "src/index.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=22"
|
|
36
|
+
},
|
|
37
|
+
"packageManager": "pnpm@11.9.0",
|
|
38
|
+
"scripts": {
|
|
39
|
+
"__c0": "====================[ Build & Development ]======================",
|
|
40
|
+
"prepack": "pnpm build",
|
|
41
|
+
"build": "tsup-node && pnpm build:types",
|
|
42
|
+
"build:types": "tsc -p tsconfig.build.json",
|
|
43
|
+
"start": "tsup-node --watch",
|
|
44
|
+
"__c1": "====================[ Linting & Type Checking ]==================",
|
|
45
|
+
"check": "npx biome check . && pnpm ts:check",
|
|
46
|
+
"format": "npx biome check . --fix",
|
|
47
|
+
"ts:check": "tsc --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"lighthouse": "^12 || ^13"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@biomejs/biome": "^2",
|
|
54
|
+
"@types/node": "^24",
|
|
55
|
+
"lighthouse": "^13",
|
|
56
|
+
"tsup": "^8",
|
|
57
|
+
"typescript": "^7"
|
|
58
|
+
}
|
|
59
|
+
}
|