Skip to content

Commit b57a1c7

Browse files
committed
feat: add category filtering for Lighthouse config and prompts
- Introduced category filtering in the Lighthouse configuration, allowing users to specify a subset of categories for audits. - Added tests to validate the behavior of category filtering, including handling of empty and undefined categories. - Implemented a new function to parse category flags from command line inputs, ensuring only valid categories are accepted. - Updated prompts to allow users to select categories interactively if not provided via command line. - Exported LAB_CATEGORIES for easier access to available categories. - Updated type definitions to include categories in lab options. - Bumped version to 0.2.1 and updated dependencies, including Lighthouse to version 13.3.0.
1 parent d59c47c commit b57a1c7

13 files changed

Lines changed: 265 additions & 115 deletions

bin/web-perf.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,19 @@ async function labAction(url, options, cmd) {
2525
const logger = require('../lib/logger');
2626
const stripJsonPropsOpt = cmd?.getOptionValueSource('stripJsonProps') === 'cli' ? options.stripJsonProps : undefined;
2727
const cleanOpt = cmd?.getOptionValueSource('clean') === 'cli' ? options.clean : undefined;
28+
const { LAB_CATEGORIES } = require('../lib/profiles');
2829
const resolved = await promptLab(url, { ...options, stripJsonProps: stripJsonPropsOpt, clean: cleanOpt });
2930
const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits;
3031
const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns;
32+
// promptLab always sets resolved.categories (parsed from --category or the checkbox)
33+
const categories = resolved.categories || [];
3134
const stripJsonProps = resolved.stripJsonProps ?? options.stripJsonProps;
3235
const clean = resolved.clean ?? false;
3336

37+
if (categories.length > 0 && categories.length < LAB_CATEGORIES.length) {
38+
logger.info(`Categories: ${categories.join(', ')}`);
39+
}
40+
3441
const totalUrls = resolved.urls.length;
3542
const totalRuns = totalUrls * resolved.runs.length;
3643
const isBatch = totalUrls > 1;
@@ -59,7 +66,7 @@ async function labAction(url, options, cmd) {
5966
}
6067
try {
6168
// eslint-disable-next-line no-await-in-loop
62-
const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, stripJsonProps, clean, port: chrome.port, silent: isBatch });
69+
const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, categories, stripJsonProps, clean, port: chrome.port, silent: isBatch });
6370
results.push({ url: targetUrl, profile: label, outputPath });
6471
if (!isBatch) {
6572
const elapsed = formatElapsed(Date.now() - startTime);
@@ -434,6 +441,7 @@ program
434441
.option('--urls-file <path>', 'Path to a file with one URL per line')
435442
.option('--skip-audits <audits>', 'Comma-separated audits to skip (default: full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps)')
436443
.option('--blocked-url-patterns <patterns>', 'Comma-separated URL patterns to block during audit (e.g. *.google-analytics.com,*.facebook.net)')
444+
.option('--category <categories>', 'Lighthouse categories, comma-separated: performance, accessibility, best-practices, seo, agentic-browsing (default: all)')
437445
.option('--no-strip-json-props', 'Disable stripping of unneeded properties (i18n, timing) from JSON output')
438446
.option('--clean', 'Write an AI-friendly clean copy to results/lab/clean/ alongside the raw file')
439447
.action(withCatch(labAction));

lib/clean.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ const ROOT_KEYS = ['i18n', 'timing', 'configSettings', 'categoryGroups',
5555
function makeLabReport(overrides = {}) {
5656
return {
5757
requestedUrl: 'https://example.com',
58-
mainDocumentUrl: 'https://example.com', // LH13 new field
59-
finalDisplayedUrl: 'https://example.com', // LH13 new field
58+
mainDocumentUrl: 'https://example.com',
59+
finalDisplayedUrl: 'https://example.com',
6060
finalUrl: 'https://example.com',
6161
fetchTime: '2024-01-01T00:00:00.000Z',
6262
formFactor: 'desktop',

lib/lab.js

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const path = require('path');
33

44
const { cleanLabReport } = require('./clean');
55
const logger = require('./logger');
6-
const { resolveProfileSettings } = require('./profiles');
6+
const { resolveProfileSettings, LAB_CATEGORIES } = require('./profiles');
77
const { SKIPPABLE_AUDITS } = require('./prompts');
88
const { stripJsonProps } = require('./strip-props');
99
const { ensureCommandDir, buildFilename } = require('./utils');
@@ -49,19 +49,24 @@ function buildLighthouseConfig(labOptions, profileSettings = {}) {
4949
const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot');
5050
const skipAudits = rawSkipAudits.filter((a) => a !== 'full-page-screenshot');
5151
const blockedUrlPatterns = labOptions.blockedUrlPatterns || [];
52+
// Only pin onlyCategories for a proper subset; an empty or full selection keeps the
53+
// Lighthouse default (all categories, including agentic-browsing).
54+
const categories = labOptions.categories || [];
55+
const filterCategories = categories.length > 0 && categories.length < LAB_CATEGORIES.length;
5256
const settings = {
5357
...profileSettings,
5458
skipAudits,
5559
...(disableFullPageScreenshot && { disableFullPageScreenshot: true }),
5660
...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }),
61+
...(filterCategories && { onlyCategories: categories }),
5762
};
58-
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0;
63+
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0 || filterCategories;
5964
return hasSettings ? { extends: 'lighthouse:default', settings } : undefined;
6065
}
6166

6267
/**
6368
* @param {string} url
64-
* @param {{ port?: number, profile?: string, network?: string, device?: string, skipAudits?: string[], blockedUrlPatterns?: string[], stripJsonProps?: boolean, silent?: boolean }} [labOptions]
69+
* @param {{ port?: number, profile?: string, network?: string, device?: string, skipAudits?: string[], blockedUrlPatterns?: string[], categories?: string[], stripJsonProps?: boolean, silent?: boolean }} [labOptions]
6570
* @returns {Promise<LabReport>}
6671
*/
6772
async function runLabAudit(url, labOptions = {}) {

lib/lab.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,40 @@ describe('runLab Lighthouse config', () => {
6868
});
6969
});
7070

71+
describe('runLab Lighthouse config — category filtering', () => {
72+
it('sets onlyCategories for a proper subset', () => {
73+
const config = buildLighthouseConfig({ categories: ['performance', 'seo'] });
74+
expect(config.settings.onlyCategories).toEqual(['performance', 'seo']);
75+
});
76+
77+
it('sets onlyCategories when filtering to a single category (agentic-browsing)', () => {
78+
const config = buildLighthouseConfig({ categories: ['agentic-browsing'] });
79+
expect(config.settings.onlyCategories).toEqual(['agentic-browsing']);
80+
});
81+
82+
it('omits onlyCategories when all five categories are selected (Lighthouse default)', () => {
83+
const config = buildLighthouseConfig({
84+
categories: ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing'],
85+
});
86+
expect(config.settings.onlyCategories).toBeUndefined();
87+
});
88+
89+
it('omits onlyCategories when categories is empty or undefined', () => {
90+
expect(buildLighthouseConfig({ categories: [] }).settings.onlyCategories).toBeUndefined();
91+
expect(buildLighthouseConfig({}).settings.onlyCategories).toBeUndefined();
92+
});
93+
94+
it('composes onlyCategories with profile settings and skipAudits', () => {
95+
const config = buildLighthouseConfig(
96+
{ categories: ['performance'], skipAudits: ['screenshot-thumbnails'] },
97+
{ formFactor: 'desktop' },
98+
);
99+
expect(config.settings.onlyCategories).toEqual(['performance']);
100+
expect(config.settings.formFactor).toBe('desktop');
101+
expect(config.settings.skipAudits).toEqual(['screenshot-thumbnails']);
102+
});
103+
});
104+
71105
describe('runLabAudit — I/O isolation', () => {
72106
afterEach(() => {
73107
vi.restoreAllMocks();

lib/profiles.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
const MOBILE_UA = 'Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36';
22
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36';
33

4+
const LAB_CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing'];
5+
46
const NETWORK_PRESETS = {
57
'3g-slow': {
68
rttMs: 400,
@@ -225,6 +227,7 @@ module.exports = {
225227
PROFILES,
226228
NETWORK_PRESETS,
227229
DEVICE_PRESETS,
230+
LAB_CATEGORIES,
228231
MOBILE_UA,
229232
DESKTOP_UA,
230233
resolveProfileSettings,

lib/profiles.test.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, it, expect } from 'vitest';
22

3-
const { buildThrottling, buildScreenEmulation, resolveProfileSettings } = require('./profiles');
3+
const { buildThrottling, buildScreenEmulation, resolveProfileSettings, LAB_CATEGORIES } = require('./profiles');
4+
5+
describe('LAB_CATEGORIES', () => {
6+
it('exports the five Lighthouse 13 category IDs', () => {
7+
expect(LAB_CATEGORIES).toEqual(['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']);
8+
});
9+
});
410

511
describe('buildThrottling', () => {
612
it('should return correct throttling object for preset', () => {

lib/prompts.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const fs = require('fs');
22

33
const { CRUX_FORM_FACTORS, DEFAULT_CRUX_FORM_FACTORS } = require('./crux');
44
const logger = require('./logger');
5-
const { PROFILES, NETWORK_PRESETS, DEVICE_PRESETS } = require('./profiles');
5+
const { PROFILES, NETWORK_PRESETS, DEVICE_PRESETS, LAB_CATEGORIES } = require('./profiles');
66
const { PSI_STRATEGIES, DEFAULT_PSI_STRATEGIES } = require('./psi');
77

88
// Lazy-load inquirer (~230ms cold require) — only needed when prompts actually run
@@ -203,6 +203,19 @@ function parseProfileFlag(profileStr) {
203203
return names;
204204
}
205205

206+
function parseLabCategoriesFlag(categoryStr) {
207+
if (!categoryStr) {
208+
return [];
209+
}
210+
const names = categoryStr.split(',').map((c) => c.trim().toLowerCase()).filter(Boolean);
211+
for (const name of names) {
212+
if (!LAB_CATEGORIES.includes(name)) {
213+
throw new Error(`Unknown category: "${name}". Valid categories: ${LAB_CATEGORIES.join(', ')}`);
214+
}
215+
}
216+
return [...new Set(names)];
217+
}
218+
206219
function parseCruxFormFactors(formFactorStr) {
207220
if (!formFactorStr) {
208221
return [...DEFAULT_CRUX_FORM_FACTORS];
@@ -350,6 +363,24 @@ async function promptLab(url, options) {
350363
}
351364
}
352365

366+
// Category selection — prompt if not provided via --category flag.
367+
// All categories (including agentic-browsing) are checked by default.
368+
if (options.category) {
369+
resolved.categories = parseLabCategoriesFlag(options.category);
370+
} else {
371+
assertTTY();
372+
const categoryChoices = LAB_CATEGORIES.map((c) => ({ name: c, value: c, checked: true }));
373+
const { categories } = await inquirer.prompt([
374+
{
375+
type: 'checkbox',
376+
name: 'categories',
377+
message: 'Lighthouse categories (all selected by default):',
378+
choices: categoryChoices,
379+
},
380+
]);
381+
resolved.categories = categories;
382+
}
383+
353384
// Skip audits selection — prompt if not provided via --skip-audits flag
354385
if (!options.skipAudits) {
355386
assertTTY();
@@ -872,6 +903,7 @@ module.exports = {
872903
parseProfileFlag,
873904
parsePsiStrategies,
874905
parseCruxFormFactors,
906+
parseLabCategoriesFlag,
875907
parseSkipAuditsFlag,
876908
parseBlockedUrlPatternsFlag,
877909
validateUrl,

lib/prompts.test.js

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,44 @@ describe('parseBlockedUrlPatternsFlag', () => {
224224
});
225225
});
226226

227+
describe('parseLabCategoriesFlag', () => {
228+
it('should return empty array for falsy input', () => {
229+
expect(prompts.parseLabCategoriesFlag(undefined)).toEqual([]);
230+
expect(prompts.parseLabCategoriesFlag('')).toEqual([]);
231+
});
232+
233+
it('should parse a single category', () => {
234+
expect(prompts.parseLabCategoriesFlag('performance')).toEqual(['performance']);
235+
});
236+
237+
it('should parse comma-separated categories and preserve order', () => {
238+
expect(prompts.parseLabCategoriesFlag('seo,performance')).toEqual(['seo', 'performance']);
239+
});
240+
241+
it('should accept agentic-browsing', () => {
242+
expect(prompts.parseLabCategoriesFlag('agentic-browsing')).toEqual(['agentic-browsing']);
243+
});
244+
245+
it('should normalize case and trim whitespace', () => {
246+
expect(prompts.parseLabCategoriesFlag(' Performance , SEO ')).toEqual(['performance', 'seo']);
247+
});
248+
249+
it('should de-duplicate categories', () => {
250+
expect(prompts.parseLabCategoriesFlag('performance,performance,seo')).toEqual(['performance', 'seo']);
251+
});
252+
253+
it('should throw on unknown category', () => {
254+
expect(() => prompts.parseLabCategoriesFlag('pwa')).toThrow('Unknown category: "pwa". Valid categories: performance, accessibility, best-practices, seo, agentic-browsing');
255+
});
256+
257+
it('should throw on unknown category in comma list', () => {
258+
expect(() => prompts.parseLabCategoriesFlag('performance,foo')).toThrow('Unknown category: "foo"');
259+
});
260+
});
261+
227262
describe('promptLab', () => {
228-
const baseOpts = { profile: undefined, network: undefined, device: undefined, urls: undefined, urlsFile: undefined, clean: false };
263+
// category set so the interactive category prompt is skipped in tests that don't exercise it
264+
const baseOpts = { profile: undefined, network: undefined, device: undefined, urls: undefined, urlsFile: undefined, category: 'performance', clean: false };
229265

230266
it('should return runs array with single profile when provided via flag', async () => {
231267
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
@@ -584,6 +620,35 @@ describe('promptLab', () => {
584620
expect(promptSpy).toHaveBeenCalledTimes(3);
585621
});
586622
});
623+
624+
describe('--category', () => {
625+
it('parses categories from the --category flag without prompting', async () => {
626+
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
627+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
628+
promptSpy.mockResolvedValueOnce({ stripJsonProps: true });
629+
const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', category: 'agentic-browsing,seo' });
630+
expect(result.categories).toEqual(['agentic-browsing', 'seo']);
631+
expect(promptSpy).toHaveBeenCalledTimes(3);
632+
});
633+
634+
it('prompts with a checkbox (all selected by default) when no --category flag', async () => {
635+
promptSpy.mockResolvedValueOnce({ categories: ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing'] });
636+
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
637+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
638+
promptSpy.mockResolvedValueOnce({ stripJsonProps: true });
639+
const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', category: undefined });
640+
expect(result.categories).toEqual(['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']);
641+
});
642+
643+
it('returns the subset selected in the interactive checkbox', async () => {
644+
promptSpy.mockResolvedValueOnce({ categories: ['agentic-browsing'] });
645+
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
646+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
647+
promptSpy.mockResolvedValueOnce({ stripJsonProps: true });
648+
const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', category: undefined });
649+
expect(result.categories).toEqual(['agentic-browsing']);
650+
});
651+
});
587652
});
588653

589654
describe('promptPsi', () => {

0 commit comments

Comments
 (0)