Skip to content

Commit 1a771aa

Browse files
committed
feat(lab): add support for blocking URL patterns and skipping audits during audits
1 parent eb3eb60 commit 1a771aa

6 files changed

Lines changed: 100 additions & 4 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ node bin/web-perf.js lab --network=3g --device=iphone-12 <url>
1515
node bin/web-perf.js lab --profile=low --network=wifi <url> # override parcial
1616
node bin/web-perf.js list-profiles
1717

18+
# Lab: Block URL patterns (prevent asset downloads during audit)
19+
node bin/web-perf.js lab --blocked-url-patterns='*.google-analytics.com,*.facebook.net' <url>
20+
node bin/web-perf.js lab --profile=low --blocked-url-patterns='*.ads.example.com' <url>
21+
1822
# Lab: Multiple URLs (<url> argument is ignored when --urls or --urls-file is provided)
1923
node bin/web-perf.js lab --urls=<url1>,<url2> --profile=low
2024
node bin/web-perf.js lab --urls-file=<urls.txt> --profile=all

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Available commands: `lab`, `rum`, `collect`, `collect-history`, `links`, `sitema
5252

5353
| Command | Source | Result | Options |
5454
|---------|--------|--------|---------|
55-
| `lab` | Local Lighthouse audit (headless Chrome) | JSON report with performance scores and Web Vitals | `--profile`, `--network`, `--device`, `--urls`, `--urls-file` |
55+
| `lab` | Local Lighthouse audit (headless Chrome) | JSON report with performance scores and Web Vitals | `--profile`, `--network`, `--device`, `--urls`, `--urls-file`, `--skip-audits`, `--blocked-url-patterns` |
5656
| `rum` | PageSpeed Insights API (real-user data + Lighthouse) | JSON with field metrics and lab scores | `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--category`, `--concurrency`, `--delay` |
5757
| `collect` | CrUX API (origin or page, 28-day rolling average) | JSON with p75 Web Vitals and metric distributions | `--scope`, `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--concurrency`, `--delay` |
5858
| `collect-history` | CrUX History API (~6 months of weekly data points) | JSON with historical Web Vitals over time | `--scope`, `--api-key`, `--api-key-path`, `--urls`, `--urls-file`, `--concurrency`, `--delay` |
@@ -88,6 +88,13 @@ node bin/web-perf.js lab --network=3g --device=iphone-12 <url>
8888
# Profile with partial override (low device + wifi network)
8989
node bin/web-perf.js lab --profile=low --network=wifi <url>
9090

91+
# Skip specific audits
92+
node bin/web-perf.js lab --skip-audits=full-page-screenshot,screenshot-thumbnails <url>
93+
94+
# Block URL patterns (prevent asset downloads during audit, e.g. analytics, ads)
95+
node bin/web-perf.js lab --blocked-url-patterns='*.google-analytics.com,*.facebook.net' <url>
96+
node bin/web-perf.js lab --profile=low --blocked-url-patterns='*.ads.example.com' <url>
97+
9198
# Multiple URLs (<url> argument is ignored when --urls or --urls-file is provided)
9299
node bin/web-perf.js lab --urls=<url1>,<url2> --profile=low
93100
node bin/web-perf.js lab --urls-file=<urls.txt> --profile=all
@@ -101,6 +108,8 @@ node bin/web-perf.js lab --urls-file=<urls.txt> --profile=all
101108
| `--device <preset>` | No | Device emulation: `moto-g-power`, `iphone-12`, `iphone-14`, `ipad`, `desktop`, `desktop-large` |
102109
| `--urls <urls>` | No | Comma-separated list of URLs to audit |
103110
| `--urls-file <path>` | No | Path to a file with one URL per line |
111+
| `--skip-audits <audits>` | No | Comma-separated Lighthouse audits to skip. Default: `full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps` |
112+
| `--blocked-url-patterns <patterns>` | No | Comma-separated URL patterns to block during the audit (e.g. `*.google-analytics.com,*.facebook.net`). Uses Chrome DevTools Protocol to prevent matching assets from being downloaded |
104113

105114
Run `list-profiles`, `list-networks`, or `list-devices` to see all available presets:
106115

bin/web-perf.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ const { name, version } = require('../package.json');
77
async function labAction(url, options) {
88
try {
99
const chromeLauncher = require('chrome-launcher');
10-
const { promptLab, parseSkipAuditsFlag } = require('../lib/prompts');
10+
const { promptLab, parseSkipAuditsFlag, parseBlockedUrlPatternsFlag } = require('../lib/prompts');
1111
const { runLab, CHROME_FLAGS } = require('../lib/lab');
1212
const { formatElapsed } = require('../lib/utils');
1313
const logger = require('../lib/logger');
1414
const resolved = await promptLab(url, options);
1515
const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits;
16+
const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns;
1617

1718
const totalUrls = resolved.urls.length;
1819
const totalRuns = totalUrls * resolved.runs.length;
@@ -42,7 +43,7 @@ async function labAction(url, options) {
4243
}
4344
try {
4445
// eslint-disable-next-line no-await-in-loop
45-
const outputPath = await runLab(targetUrl, { ...run, skipAudits, port: chrome.port, silent: isBatch });
46+
const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, port: chrome.port, silent: isBatch });
4647
results.push({ url: targetUrl, profile: label, outputPath });
4748
if (!isBatch) {
4849
const elapsed = formatElapsed(Date.now() - startTime);
@@ -375,6 +376,7 @@ program
375376
.option('--urls <urls>', 'Comma-separated list of URLs')
376377
.option('--urls-file <path>', 'Path to a file with one URL per line')
377378
.option('--skip-audits <audits>', 'Comma-separated audits to skip (default: full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps)')
379+
.option('--blocked-url-patterns <patterns>', 'Comma-separated URL patterns to block during audit (e.g. *.google-analytics.com,*.facebook.net)')
378380
.action(labAction);
379381

380382
program

lib/lab.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ async function runLab(url, labOptions = {}) {
6666
logLevel: 'error',
6767
};
6868
const skipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS;
69+
const blockedUrlPatterns = labOptions.blockedUrlPatterns || [];
6970
const settings = {
7071
...profileSettings,
7172
skipAudits,
73+
...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }),
7274
};
73-
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0;
75+
const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || blockedUrlPatterns.length > 0;
7476
const config = hasSettings
7577
? { extends: 'lighthouse:default', settings }
7678
: undefined;

lib/prompts.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ function parseSkipAuditsFlag(skipAuditsStr) {
152152
return skipAuditsStr.split(',').map((a) => a.trim()).filter(Boolean);
153153
}
154154

155+
function parseBlockedUrlPatternsFlag(patternsStr) {
156+
if (!patternsStr) {
157+
return undefined;
158+
}
159+
return [...new Set(patternsStr.split(',').map((p) => p.trim()).filter(Boolean))];
160+
}
161+
155162
function parseProfileFlag(profileStr) {
156163
if (!profileStr) {
157164
return [];
@@ -300,6 +307,20 @@ async function promptLab(url, options) {
300307
resolved.skipAudits = skipAudits;
301308
}
302309

310+
// Blocked URL patterns (only in interactive mode, not when --blocked-url-patterns flag is used)
311+
if (!options.blockedUrlPatterns) {
312+
const { blockedUrlPatternsInput } = await inquirer.prompt([
313+
{
314+
type: 'input',
315+
name: 'blockedUrlPatternsInput',
316+
message: 'URL patterns to block (comma-separated, blank for none):',
317+
},
318+
]);
319+
if (blockedUrlPatternsInput) {
320+
resolved.blockedUrlPatterns = blockedUrlPatternsInput.split(',').map((p) => p.trim()).filter(Boolean);
321+
}
322+
}
323+
303324
return resolved;
304325
}
305326

@@ -616,6 +637,7 @@ module.exports = {
616637
promptLinks,
617638
parseProfileFlag,
618639
parseSkipAuditsFlag,
640+
parseBlockedUrlPatternsFlag,
619641
validateUrl,
620642
validatePositiveInt,
621643
validateNonNegativeInt,

lib/prompts.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,33 @@ describe('parseSkipAuditsFlag', () => {
144144
});
145145
});
146146

147+
describe('parseBlockedUrlPatternsFlag', () => {
148+
it('should return undefined for falsy input', () => {
149+
expect(prompts.parseBlockedUrlPatternsFlag(undefined)).toBeUndefined();
150+
expect(prompts.parseBlockedUrlPatternsFlag('')).toBeUndefined();
151+
});
152+
153+
it('should parse a single pattern', () => {
154+
expect(prompts.parseBlockedUrlPatternsFlag('*.google-analytics.com')).toEqual(['*.google-analytics.com']);
155+
});
156+
157+
it('should parse comma-separated patterns', () => {
158+
expect(prompts.parseBlockedUrlPatternsFlag('*.google-analytics.com,*.facebook.net')).toEqual(['*.google-analytics.com', '*.facebook.net']);
159+
});
160+
161+
it('should trim whitespace around patterns', () => {
162+
expect(prompts.parseBlockedUrlPatternsFlag(' *.google-analytics.com , *.facebook.net ')).toEqual(['*.google-analytics.com', '*.facebook.net']);
163+
});
164+
165+
it('should remove duplicate patterns', () => {
166+
expect(prompts.parseBlockedUrlPatternsFlag(' *.google-analytics.com , *.facebook.net , *.google-analytics.com ')).toEqual(['*.google-analytics.com', '*.facebook.net']);
167+
});
168+
169+
it('should ignore empty segments from trailing commas', () => {
170+
expect(prompts.parseBlockedUrlPatternsFlag('*.google-analytics.com,,*.facebook.net,')).toEqual(['*.google-analytics.com', '*.facebook.net']);
171+
});
172+
});
173+
147174
describe('promptLab', () => {
148175
const baseOpts = { profile: undefined, network: undefined, device: undefined, urls: undefined, urlsFile: undefined };
149176

@@ -179,20 +206,23 @@ describe('promptLab', () => {
179206
promptSpy.mockResolvedValueOnce({ urls: 'https://prompted.com' });
180207
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
181208
promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot'] });
209+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
182210
const result = await prompts.promptLab(undefined, baseOpts);
183211
expect(result.urls).toEqual(['https://prompted.com/']);
184212
});
185213

186214
it('should prompt with checkbox for profiles when no flags given', async () => {
187215
promptSpy.mockResolvedValueOnce({ profiles: ['high'] });
188216
promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot'] });
217+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
189218
const result = await prompts.promptLab('https://example.com', baseOpts);
190219
expect(result.runs).toEqual([{ profile: 'high', network: undefined, device: undefined }]);
191220
});
192221

193222
it('should expand "all" selection in interactive mode', async () => {
194223
promptSpy.mockResolvedValueOnce({ profiles: ['all'] });
195224
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
225+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
196226
const result = await prompts.promptLab('https://example.com', baseOpts);
197227
expect(result.runs).toHaveLength(4);
198228
expect(result.runs.map((r) => r.profile)).toEqual(['low', 'medium', 'high', 'native']);
@@ -202,6 +232,7 @@ describe('promptLab', () => {
202232
promptSpy.mockResolvedValueOnce({ profiles: ['custom'] });
203233
promptSpy.mockResolvedValueOnce({ network: '3g', device: 'iphone-12' });
204234
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
235+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
205236
const result = await prompts.promptLab('https://example.com', baseOpts);
206237
expect(result.runs).toEqual([{ profile: undefined, network: '3g', device: 'iphone-12' }]);
207238
});
@@ -210,6 +241,7 @@ describe('promptLab', () => {
210241
promptSpy.mockResolvedValueOnce({ profiles: ['low', 'custom'] });
211242
promptSpy.mockResolvedValueOnce({ network: 'wifi', device: 'desktop' });
212243
promptSpy.mockResolvedValueOnce({ skipAudits: ['final-screenshot'] });
244+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
213245
const result = await prompts.promptLab('https://example.com', baseOpts);
214246
expect(result.runs).toEqual([
215247
{ profile: 'low', network: undefined, device: undefined },
@@ -245,20 +277,23 @@ describe('promptLab', () => {
245277
promptSpy.mockResolvedValueOnce({ urls: 'https://a.com, https://b.com' });
246278
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
247279
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
280+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
248281
const result = await prompts.promptLab(undefined, baseOpts);
249282
expect(result.urls).toEqual(['https://a.com/', 'https://b.com/']);
250283
});
251284

252285
it('should return empty skipAudits when nothing selected', async () => {
253286
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
254287
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
288+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
255289
const result = await prompts.promptLab('https://example.com', baseOpts);
256290
expect(result.skipAudits).toEqual([]);
257291
});
258292

259293
it('should return selected skipAudits from interactive prompt', async () => {
260294
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
261295
promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot', 'network-requests'] });
296+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
262297
const result = await prompts.promptLab('https://example.com', baseOpts);
263298
expect(result.skipAudits).toEqual(['full-page-screenshot', 'network-requests']);
264299
});
@@ -269,6 +304,28 @@ describe('promptLab', () => {
269304
expect(promptSpy).not.toHaveBeenCalled();
270305
});
271306

307+
it('should return blockedUrlPatterns from interactive prompt', async () => {
308+
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
309+
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
310+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '*.google-analytics.com, *.facebook.net' });
311+
const result = await prompts.promptLab('https://example.com', baseOpts);
312+
expect(result.blockedUrlPatterns).toEqual(['*.google-analytics.com', '*.facebook.net']);
313+
});
314+
315+
it('should not set blockedUrlPatterns when interactive input is blank', async () => {
316+
promptSpy.mockResolvedValueOnce({ profiles: ['low'] });
317+
promptSpy.mockResolvedValueOnce({ skipAudits: [] });
318+
promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' });
319+
const result = await prompts.promptLab('https://example.com', baseOpts);
320+
expect(result.blockedUrlPatterns).toBeUndefined();
321+
});
322+
323+
it('should not prompt for blockedUrlPatterns when --blocked-url-patterns flag is provided', async () => {
324+
const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', blockedUrlPatterns: '*.google-analytics.com' });
325+
expect(result.blockedUrlPatterns).toBeUndefined();
326+
expect(promptSpy).not.toHaveBeenCalled();
327+
});
328+
272329
it('should strip query strings and hashes from URLs', async () => {
273330
const result = await prompts.promptLab(undefined, {
274331
...baseOpts,

0 commit comments

Comments
 (0)