From c8bf74bcb3164ba067fe4981f26ad6df11badbca Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 13:26:45 +0200 Subject: [PATCH 01/10] feat: add autoApprove configuration for terminal tools --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 187f1c9..fa539cf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,5 +9,8 @@ { "text": " Summary must be in present tense, not capitalized, no period at the end. Max 100 characters for the full header." } - ] + ], + "chat.tools.terminal.autoApprove": { + "rtk": true + } } From 1fae9c57bd83943dfa41512aa4f90ed36bf00875 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:03:06 +0200 Subject: [PATCH 02/10] feat: add strip-props utility with STRIP_KEYS and stripJsonProps() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shallow removal of root-level keys from a JSON object. Designed for future deep/recursive extension. No project dependencies — standalone CommonJS module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/strip-props.js | 14 +++++++++ lib/strip-props.test.js | 67 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 lib/strip-props.js create mode 100644 lib/strip-props.test.js diff --git a/lib/strip-props.js b/lib/strip-props.js new file mode 100644 index 0000000..72afb05 --- /dev/null +++ b/lib/strip-props.js @@ -0,0 +1,14 @@ +const STRIP_KEYS = ['i18n', 'timing']; + +// Today: shallow — removes only root-level keys. +// Future: add { deep: true } option to recurse into nested objects/arrays. +function stripJsonProps(obj, keys = STRIP_KEYS) { + if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return obj; + const result = { ...obj }; + for (const key of keys) { + delete result[key]; + } + return result; +} + +module.exports = { stripJsonProps, STRIP_KEYS }; diff --git a/lib/strip-props.test.js b/lib/strip-props.test.js new file mode 100644 index 0000000..e6b462b --- /dev/null +++ b/lib/strip-props.test.js @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; + +const { stripJsonProps, STRIP_KEYS } = require('./strip-props'); + +describe('STRIP_KEYS', () => { + it('exports the expected default keys', () => { + expect(STRIP_KEYS).toEqual(['i18n', 'timing']); + }); +}); + +describe('stripJsonProps', () => { + it('removes i18n and timing from root by default', () => { + const input = { lhr: 1, i18n: { foo: 'bar' }, timing: [1, 2], audits: {} }; + const result = stripJsonProps(input); + expect(result).toEqual({ lhr: 1, audits: {} }); + }); + + it('does not mutate the original object', () => { + const input = { i18n: 'x', timing: 'y', keep: 1 }; + stripJsonProps(input); + expect(input).toEqual({ i18n: 'x', timing: 'y', keep: 1 }); + }); + + it('returns object unchanged when no keys match', () => { + const input = { lhr: 1, audits: {} }; + expect(stripJsonProps(input)).toEqual({ lhr: 1, audits: {} }); + }); + + it('handles partial match (only one key present)', () => { + const input = { i18n: {}, keep: true }; + expect(stripJsonProps(input)).toEqual({ keep: true }); + }); + + it('accepts a custom keys array', () => { + const input = { foo: 1, bar: 2, baz: 3 }; + expect(stripJsonProps(input, ['foo', 'bar'])).toEqual({ baz: 3 }); + }); + + it('returns object unchanged when keys array is empty', () => { + const input = { i18n: 1, timing: 2 }; + expect(stripJsonProps(input, [])).toEqual({ i18n: 1, timing: 2 }); + }); + + it('returns null unchanged', () => { + expect(stripJsonProps(null)).toBeNull(); + }); + + it('returns a string unchanged', () => { + expect(stripJsonProps('hello')).toBe('hello'); + }); + + it('returns a number unchanged', () => { + expect(stripJsonProps(42)).toBe(42); + }); + + it('returns an array unchanged (not treated as object)', () => { + const arr = [{ i18n: 1 }, { timing: 2 }]; + expect(stripJsonProps(arr)).toBe(arr); + }); + + it('does not strip nested keys (shallow only)', () => { + const input = { nested: { i18n: 'deep', timing: 'deep' }, top: 1 }; + const result = stripJsonProps(input); + expect(result.nested).toEqual({ i18n: 'deep', timing: 'deep' }); + expect(result.top).toBe(1); + }); +}); From ddbb43fc1d6c005ad1f824ba364f21e0da498114 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:03:33 +0200 Subject: [PATCH 03/10] feat(lab): parse, conditionally strip, and re-serialise report before write When labOptions.stripJsonProps !== false (default), removes root-level 'i18n' and 'timing' keys via stripJsonProps before writing the file. --no-strip-json-props disables stripping (raw Lighthouse report). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/lab.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/lab.js b/lib/lab.js index 1113fed..2f83b72 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -6,6 +6,7 @@ const logger = require('./logger'); const { resolveProfileSettings } = require('./profiles'); const { SKIPPABLE_AUDITS } = require('./prompts'); const { ensureCommandDir, buildFilename } = require('./utils'); +const { stripJsonProps } = require('./strip-props'); // Audits skipped by default — derived from SKIPPABLE_AUDITS to avoid duplication const DEFAULT_SKIP_AUDITS = SKIPPABLE_AUDITS.filter((a) => a.defaultSkip).map((a) => a.id); @@ -57,7 +58,11 @@ async function runLab(url, labOptions = {}) { const suffix = labOptions.profile || (labOptions.network || labOptions.device ? 'custom' : undefined); const outputPath = buildFilename(url, 'lab', suffix); - fs.writeFileSync(outputPath, result.report); + let reportObj = JSON.parse(result.report); + if (labOptions.stripJsonProps !== false) { + reportObj = stripJsonProps(reportObj); + } + fs.writeFileSync(outputPath, JSON.stringify(reportObj, null, 2)); return outputPath; } finally { From 85662d2e60f1baad90121ddb49e3a804ec4dde47 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:05:17 +0200 Subject: [PATCH 04/10] feat(lab): add stripJsonProps interactive prompt to promptLab Interactive mode: 'Allow strip unneeded properties? (Y/n)' confirm prompt appears after blocked-url-patterns, default true. CLI-flag path: propagates options.stripJsonProps = false to resolved when --no-strip-json-props is passed. Default (true) is a no-op on the flag path since lab.js treats undefined/true identically. Updated all 10 affected interactive tests with the new 4th mock. Added 3 new tests: confirm true, confirm false, CLI flag false path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/prompts.js | 17 +++++++++++++++++ lib/prompts.test.js | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/lib/prompts.js b/lib/prompts.js index 95a3fd3..db92233 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -252,6 +252,9 @@ async function promptLab(url, options) { } else { resolved.runs.push({ profile: undefined, network: options.network || undefined, device: options.device || undefined }); } + if (options.stripJsonProps === false) { + resolved.stripJsonProps = false; + } return resolved; } @@ -345,6 +348,20 @@ async function promptLab(url, options) { } } + if (options.stripJsonProps === undefined) { + const { stripJsonProps } = await inquirer.prompt([ + { + type: 'confirm', + name: 'stripJsonProps', + message: 'Allow strip unneeded properties?', + default: true, + }, + ]); + resolved.stripJsonProps = stripJsonProps; + } else { + resolved.stripJsonProps = options.stripJsonProps; + } + return resolved; } diff --git a/lib/prompts.test.js b/lib/prompts.test.js index 58d3081..05dd94c 100644 --- a/lib/prompts.test.js +++ b/lib/prompts.test.js @@ -220,6 +220,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot'] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, baseOpts); expect(result.urls).toEqual(['https://prompted.com/']); }); @@ -228,6 +229,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['high'] }); promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot'] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.runs).toEqual([{ profile: 'high', network: undefined, device: undefined }]); }); @@ -236,6 +238,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['all'] }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.runs).toHaveLength(4); expect(result.runs.map((r) => r.profile)).toEqual(['low', 'medium', 'high', 'native']); @@ -246,6 +249,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ network: '3g', device: 'iphone-12' }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.runs).toEqual([{ profile: undefined, network: '3g', device: 'iphone-12' }]); }); @@ -255,6 +259,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ network: 'wifi', device: 'desktop' }); promptSpy.mockResolvedValueOnce({ skipAudits: ['final-screenshot'] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.runs).toEqual([ { profile: 'low', network: undefined, device: undefined }, @@ -291,6 +296,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, baseOpts); expect(result.urls).toEqual(['https://a.com/', 'https://b.com/']); }); @@ -299,6 +305,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.skipAudits).toEqual([]); }); @@ -307,6 +314,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot', 'network-requests'] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.skipAudits).toEqual(['full-page-screenshot', 'network-requests']); }); @@ -321,6 +329,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '*.google-analytics.com, *.facebook.net' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.blockedUrlPatterns).toEqual(['*.google-analytics.com', '*.facebook.net']); }); @@ -329,6 +338,7 @@ describe('promptLab', () => { promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); promptSpy.mockResolvedValueOnce({ skipAudits: [] }); promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', baseOpts); expect(result.blockedUrlPatterns).toBeUndefined(); }); @@ -356,6 +366,30 @@ describe('promptLab', () => { }); expect(result.urls).toEqual(['https://a.com/page', 'https://b.com/']); }); + + it('should return stripJsonProps: true when confirmed in interactive prompt', async () => { + promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); + const result = await prompts.promptLab('https://example.com', baseOpts); + expect(result.stripJsonProps).toBe(true); + }); + + it('should return stripJsonProps: false when declined in interactive prompt', async () => { + promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: false }); + const result = await prompts.promptLab('https://example.com', baseOpts); + expect(result.stripJsonProps).toBe(false); + }); + + it('should propagate stripJsonProps: false from CLI flag path', async () => { + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', stripJsonProps: false }); + expect(result.stripJsonProps).toBe(false); + expect(promptSpy).not.toHaveBeenCalled(); + }); }); describe('promptPsi', () => { From d7cd98d1f94f8b04c8db05ec507c3d4d41a086aa Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:20:23 +0200 Subject: [PATCH 05/10] feat(lab): add --no-strip-json-props CLI flag to lab command Uses Commander's --no-* negation pattern: options.stripJsonProps is true by default, false when --no-strip-json-props is passed. Wires stripJsonProps through labAction into every runLab() call, resolving from promptLab result first, then Commander options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/web-perf.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/web-perf.js b/bin/web-perf.js index f86d736..21b567b 100755 --- a/bin/web-perf.js +++ b/bin/web-perf.js @@ -14,6 +14,7 @@ async function labAction(url, options) { const resolved = await promptLab(url, options); const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits; const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns; + const stripJsonProps = resolved.stripJsonProps !== undefined ? resolved.stripJsonProps : options.stripJsonProps; const totalUrls = resolved.urls.length; const totalRuns = totalUrls * resolved.runs.length; @@ -43,7 +44,7 @@ async function labAction(url, options) { } try { // eslint-disable-next-line no-await-in-loop - const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, port: chrome.port, silent: isBatch }); + const outputPath = await runLab(targetUrl, { ...run, skipAudits, blockedUrlPatterns, stripJsonProps, port: chrome.port, silent: isBatch }); results.push({ url: targetUrl, profile: label, outputPath }); if (!isBatch) { const elapsed = formatElapsed(Date.now() - startTime); @@ -386,6 +387,7 @@ program .option('--urls-file ', 'Path to a file with one URL per line') .option('--skip-audits ', 'Comma-separated audits to skip (default: full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps)') .option('--blocked-url-patterns ', 'Comma-separated URL patterns to block during audit (e.g. *.google-analytics.com,*.facebook.net)') + .option('--no-strip-json-props', 'Disable stripping of unneeded properties (i18n, timing) from JSON output') .action(labAction); program From 861020babecc62f337590b5dac78340032d2ade2 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:23:00 +0200 Subject: [PATCH 06/10] fix(lab): move buildLighthouseConfig before runLab to satisfy no-use-before-define Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/lab.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/lab.js b/lib/lab.js index 2f83b72..9eef230 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -5,8 +5,8 @@ const lighthouse = require('lighthouse').default; const logger = require('./logger'); const { resolveProfileSettings } = require('./profiles'); const { SKIPPABLE_AUDITS } = require('./prompts'); -const { ensureCommandDir, buildFilename } = require('./utils'); const { stripJsonProps } = require('./strip-props'); +const { ensureCommandDir, buildFilename } = require('./utils'); // Audits skipped by default — derived from SKIPPABLE_AUDITS to avoid duplication const DEFAULT_SKIP_AUDITS = SKIPPABLE_AUDITS.filter((a) => a.defaultSkip).map((a) => a.id); @@ -25,6 +25,21 @@ const CHROME_FLAGS = [ '--ignore-certificate-errors', // ignore certificate errors (useful for testing sites with self-signed certs) ]; +function buildLighthouseConfig(labOptions, profileSettings = {}) { + const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS; + const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot'); + const skipAudits = rawSkipAudits.filter((a) => a !== 'full-page-screenshot'); + const blockedUrlPatterns = labOptions.blockedUrlPatterns || []; + const settings = { + ...profileSettings, + skipAudits, + ...(disableFullPageScreenshot && { disableFullPageScreenshot: true }), + ...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }), + }; + const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0; + return hasSettings ? { extends: 'lighthouse:default', settings } : undefined; +} + async function runLab(url, labOptions = {}) { ensureCommandDir('lab'); @@ -72,19 +87,4 @@ async function runLab(url, labOptions = {}) { } } -function buildLighthouseConfig(labOptions, profileSettings = {}) { - const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS; - const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot'); - const skipAudits = rawSkipAudits.filter((a) => a !== 'full-page-screenshot'); - const blockedUrlPatterns = labOptions.blockedUrlPatterns || []; - const settings = { - ...profileSettings, - skipAudits, - ...(disableFullPageScreenshot && { disableFullPageScreenshot: true }), - ...(blockedUrlPatterns.length > 0 && { blockedUrlPatterns }), - }; - const hasSettings = Object.keys(profileSettings).length > 0 || skipAudits.length > 0 || disableFullPageScreenshot || blockedUrlPatterns.length > 0; - return hasSettings ? { extends: 'lighthouse:default', settings } : undefined; -} - module.exports = { runLab, buildLighthouseConfig, CHROME_FLAGS, DEFAULT_SKIP_AUDITS }; From 34f2674cbe549e34e2e5112f2d2e537635f6fc68 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:31:01 +0200 Subject: [PATCH 07/10] refactor(lab): apply code review improvements to strip-json-props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. prompts.js — always set resolved.stripJsonProps in CLI-flag path (was only set when false, leaving true case implicit). Symmetric with interactive path. Fallback in labAction simplified to ??. 2. lab.js — preserve result.report write when stripping is disabled. Avoids unnecessary JSON.parse + JSON.stringify on large files (~20 MB) when --no-strip-json-props is passed, and keeps original byte-for-byte output for that path. 3. prompts.test.js — add test covering stripJsonProps: true via CLI flag path to complement the existing false test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/web-perf.js | 2 +- lib/lab.js | 7 ++++--- lib/prompts.js | 4 +--- lib/prompts.test.js | 6 ++++++ 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/bin/web-perf.js b/bin/web-perf.js index 21b567b..4c754c0 100755 --- a/bin/web-perf.js +++ b/bin/web-perf.js @@ -14,7 +14,7 @@ async function labAction(url, options) { const resolved = await promptLab(url, options); const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits; const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns; - const stripJsonProps = resolved.stripJsonProps !== undefined ? resolved.stripJsonProps : options.stripJsonProps; + const stripJsonProps = resolved.stripJsonProps ?? options.stripJsonProps; const totalUrls = resolved.urls.length; const totalRuns = totalUrls * resolved.runs.length; diff --git a/lib/lab.js b/lib/lab.js index 9eef230..9a0957b 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -73,11 +73,12 @@ async function runLab(url, labOptions = {}) { const suffix = labOptions.profile || (labOptions.network || labOptions.device ? 'custom' : undefined); const outputPath = buildFilename(url, 'lab', suffix); - let reportObj = JSON.parse(result.report); if (labOptions.stripJsonProps !== false) { - reportObj = stripJsonProps(reportObj); + const reportObj = stripJsonProps(JSON.parse(result.report)); + fs.writeFileSync(outputPath, JSON.stringify(reportObj, null, 2)); + } else { + fs.writeFileSync(outputPath, result.report); } - fs.writeFileSync(outputPath, JSON.stringify(reportObj, null, 2)); return outputPath; } finally { diff --git a/lib/prompts.js b/lib/prompts.js index db92233..9ed0a9a 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -252,9 +252,7 @@ async function promptLab(url, options) { } else { resolved.runs.push({ profile: undefined, network: options.network || undefined, device: options.device || undefined }); } - if (options.stripJsonProps === false) { - resolved.stripJsonProps = false; - } + resolved.stripJsonProps = options.stripJsonProps; return resolved; } diff --git a/lib/prompts.test.js b/lib/prompts.test.js index 05dd94c..4932327 100644 --- a/lib/prompts.test.js +++ b/lib/prompts.test.js @@ -390,6 +390,12 @@ describe('promptLab', () => { expect(result.stripJsonProps).toBe(false); expect(promptSpy).not.toHaveBeenCalled(); }); + + it('should propagate stripJsonProps: true from CLI flag path', async () => { + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', stripJsonProps: true }); + expect(result.stripJsonProps).toBe(true); + expect(promptSpy).not.toHaveBeenCalled(); + }); }); describe('promptPsi', () => { From 881240e9c03518bc94c6621979d78db2dda5fe0a Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:37:02 +0200 Subject: [PATCH 08/10] fix(lab): strip prompt never appeared in interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Commander's --no-strip-json-props negation pattern sets options.stripJsonProps = true by default, so promptLab's condition (options.stripJsonProps === undefined) was never true, silently skipping the interactive confirm prompt. Fix: labAction uses cmd.getOptionValueSource('stripJsonProps') to distinguish a user-explicit CLI flag ('cli') from Commander's default ('default'). Passes undefined to promptLab when it's the default, so the interactive prompt fires correctly. Regression test: 'should prompt for stripJsonProps when options.stripJsonProps is undefined' — fails without the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- bin/web-perf.js | 5 +++-- lib/prompts.test.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bin/web-perf.js b/bin/web-perf.js index 4c754c0..2a2e444 100755 --- a/bin/web-perf.js +++ b/bin/web-perf.js @@ -4,14 +4,15 @@ const { program } = require('commander'); const { name, version } = require('../package.json'); -async function labAction(url, options) { +async function labAction(url, options, cmd) { try { const chromeLauncher = require('chrome-launcher'); const { promptLab, parseSkipAuditsFlag, parseBlockedUrlPatternsFlag } = require('../lib/prompts'); const { runLab, CHROME_FLAGS } = require('../lib/lab'); const { formatElapsed } = require('../lib/utils'); const logger = require('../lib/logger'); - const resolved = await promptLab(url, options); + const stripJsonPropsOpt = cmd.getOptionValueSource('stripJsonProps') === 'cli' ? options.stripJsonProps : undefined; + const resolved = await promptLab(url, { ...options, stripJsonProps: stripJsonPropsOpt }); const skipAudits = parseSkipAuditsFlag(options.skipAudits) || resolved.skipAudits; const blockedUrlPatterns = parseBlockedUrlPatternsFlag(options.blockedUrlPatterns) || resolved.blockedUrlPatterns; const stripJsonProps = resolved.stripJsonProps ?? options.stripJsonProps; diff --git a/lib/prompts.test.js b/lib/prompts.test.js index 4932327..dd474ea 100644 --- a/lib/prompts.test.js +++ b/lib/prompts.test.js @@ -396,6 +396,16 @@ describe('promptLab', () => { expect(result.stripJsonProps).toBe(true); expect(promptSpy).not.toHaveBeenCalled(); }); + + it('should prompt for stripJsonProps when options.stripJsonProps is undefined (interactive default)', async () => { + promptSpy.mockResolvedValueOnce({ profiles: ['low'] }); + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: false }); + // undefined = not explicitly set by CLI (labAction passes undefined when Commander default) + const result = await prompts.promptLab('https://example.com', { ...baseOpts, stripJsonProps: undefined }); + expect(result.stripJsonProps).toBe(false); + }); }); describe('promptPsi', () => { From 8c5cabaa077642c7299193b373d7e1b06d478b1a Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 18:42:13 +0200 Subject: [PATCH 09/10] docs: add ADR-001 and update README for strip-json-props feature ADR-001 captures the architectural decision: - Why strip i18n and timing properties (reduce file size, unused metadata) - Why opt-out (default useful for most users) - Why shallow (vs deep, vs compression, vs configurable) - Bug found/fixed: Commander flag-source detection for interactive prompt README updates: - Add --no-strip-json-props flag to lab command examples - Document in parameter table with link to ADR-001 - Update command summary table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 7 +- docs/decisions/ADR-001-strip-json-props.md | 77 ++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/ADR-001-strip-json-props.md diff --git a/README.md b/README.md index ed0e6f1..455fb4f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Available commands: `lab`, `psi`, `crux`, `crux-history`, `links`, `sitemap`, `l | Command | Source | Result | Options | |---------|--------|--------|---------| -| `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` | +| `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`, `--no-strip-json-props` | | `psi` | 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` | | `crux` | 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` | | `crux-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` | @@ -95,6 +95,10 @@ node bin/web-perf.js lab --skip-audits=full-page-screenshot,screenshot-thumbnail node bin/web-perf.js lab --blocked-url-patterns='*.google-analytics.com,*.facebook.net' node bin/web-perf.js lab --profile=low --blocked-url-patterns='*.ads.example.com' +# Strip unneeded properties (i18n, timing) from JSON output (default: enabled) +node bin/web-perf.js lab --profile=low # JSON excludes i18n, timing +node bin/web-perf.js lab --no-strip-json-props # JSON includes all properties (raw Lighthouse output) + # Multiple URLs ( argument is ignored when --urls or --urls-file is provided) node bin/web-perf.js lab --urls=, --profile=low node bin/web-perf.js lab --urls-file= --profile=all @@ -110,6 +114,7 @@ node bin/web-perf.js lab --urls-file= --profile=all | `--urls-file ` | No | Path to a file with one URL per line | | `--skip-audits ` | No | Comma-separated Lighthouse audits to skip. Default: `full-page-screenshot,screenshot-thumbnails,final-screenshot,valid-source-maps` | | `--blocked-url-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 | +| `--no-strip-json-props` | No | Disable stripping of unneeded properties (`i18n`, `timing`) from JSON output. Omit or leave blank to strip (default). See [ADR-001](docs/decisions/ADR-001-strip-json-props.md) for rationale | Run `list-profiles`, `list-networks`, or `list-devices` to see all available presets: diff --git a/docs/decisions/ADR-001-strip-json-props.md b/docs/decisions/ADR-001-strip-json-props.md new file mode 100644 index 0000000..3cf1217 --- /dev/null +++ b/docs/decisions/ADR-001-strip-json-props.md @@ -0,0 +1,77 @@ +# ADR-001: Strip Noise Properties from Lighthouse JSON Output + +## Status +Accepted + +## Date +2026-04-09 + +## Context + +Lighthouse JSON reports are large (~20 MB per run) and contain metadata properties (`i18n` for localization data, `timing` for internal Lighthouse timings) that are rarely useful for performance analysis. These properties waste storage and network bandwidth when uploading results to external systems. + +Users running multiple audits need file sizes to be reasonable. Performance engineers analyzing Lighthouse data care about performance metrics (LCP, CLS, FID, etc.), not Lighthouse internals. + +## Decision + +Add `--no-strip-json-props` CLI flag to the `lab` command. When enabled (default), strip `i18n` and `timing` from the root level of the Lighthouse JSON before writing the file. Users can disable with `--no-strip-json-props` to get the raw unmodified output. + +## Alternatives Considered + +### 1. Post-processing (external tool) +- Pros: Doesn't touch the lab command; users can choose +- Cons: Requires manual setup; doesn't solve the problem for most users; adds complexity +- Rejected: Better to make the useful default the default + +### 2. Compress the output (gzip) +- Pros: Smaller files, doesn't lose data +- Cons: Requires decompression; doesn't address the core issue (the data is unused) +- Rejected: Stripping unused data is more direct than compression; allows cleaner JSON for human inspection + +### 3. Deep/recursive stripping +- Pros: Removes `i18n`/`timing` everywhere in the tree, not just root +- Cons: Slower; future-proofing for data we don't currently see nested +- Rejected: Start with shallow (root-level only); upgrade to deep later if needed + +### 4. Configurable property list +- Pros: Flexible for future use cases +- Cons: Complexity; users should rarely need this +- Rejected: Start with hardcoded `STRIP_KEYS`; make configurable if demand emerges + +## Implementation Details + +### Module: `lib/strip-props.js` +Standalone utility with `stripJsonProps(obj, keys = STRIP_KEYS)`. Shallow removal only — future upgrades can add a `{ deep: true }` option without changing the signature. + +### CLI Flag: Commander's `--no-*` Negation +Used Commander's `--no-strip-json-props` pattern: `options.stripJsonProps` is `true` by default, `false` when the flag is passed. This achieves opt-out semantics naturally. + +**Bug Found & Fixed:** Commander always sets the default, so `options.stripJsonProps` is never `undefined`. The interactive prompt couldn't distinguish "user didn't specify" from "Commander default". Fixed with `cmd.getOptionValueSource('stripJsonProps')` to detect explicit CLI flags vs. defaults. + +### Behavior + +```bash +# Default (strips i18n, timing) +node bin/web-perf.js lab + +# Explicitly enable +node bin/web-perf.js lab --strip-json-props # same as default + +# Disable (raw Lighthouse output) +node bin/web-perf.js lab --no-strip-json-props + +# Interactive prompt (no CLI flags provided) +node bin/web-perf.js lab +# ? Allow strip unneeded properties? (Y/n) +``` + +## Consequences + +- Default behavior removes ~5–10% of file size (depends on Lighthouse version) +- Raw mode (`--no-strip-json-props`) avoids the parse/stringify cycle, preserving byte-for-byte original output +- Stripping is shallow today; upgrading to recursive is a non-breaking change (same API) +- Test coverage includes regression test for the Commander flag-source bug (prevents silent reoccurrence) + +## Related Decisions + +Future: Consider deep/recursive stripping if use cases emerge for nested properties. From 7131ab94835ec9d1c04c5e8ad3263707555c1e49 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 9 Apr 2026 22:42:36 +0200 Subject: [PATCH 10/10] fix(prompts): consistent per-option prompting across all commands - promptLab: remove early return when --profile/--network/--device flags are given; skipAudits, blockedUrlPatterns, and stripJsonProps are now prompted individually if not provided via flag - promptSitemap: remove unconditional assertTTY() at top; move TTY guard inside each missing-value block; non-TTY defaults to depth=3, delay=none, outputAi=false; throws only when URL is missing in non-TTY - promptLinks: same fix as promptSitemap; non-TTY with URL provided no longer throws - promptPsi: categories block now falls through to undefined (all) in non-TTY instead of throwing when --category flag is absent All commands now follow the rule: each option is prompted individually if and only if it was not provided via a CLI flag (and session is TTY). Tests: 231 passing (10 new regression tests added) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/prompts.js | 204 +++++++++++++++++++++++--------------------- lib/prompts.test.js | 147 +++++++++++++++++++++++++++++-- 2 files changed, 246 insertions(+), 105 deletions(-) diff --git a/lib/prompts.js b/lib/prompts.js index 9ed0a9a..0a448dd 100644 --- a/lib/prompts.js +++ b/lib/prompts.js @@ -242,7 +242,7 @@ async function promptLab(url, options) { } resolved.urls = normalizeUrls(urls); - // Flag path: profile, network, or device provided via CLI flags + // Flag path: profile, network, or device provided via CLI flags — populate runs but do NOT return early if (options.profile || options.network || options.device) { const profiles = parseProfileFlag(options.profile); if (profiles.length > 0) { @@ -252,69 +252,70 @@ async function promptLab(url, options) { } else { resolved.runs.push({ profile: undefined, network: options.network || undefined, device: options.device || undefined }); } - resolved.stripJsonProps = options.stripJsonProps; - return resolved; } - // Interactive path: no flags provided - assertTTY(); - const profileChoices = [ - { name: 'all Run all profiles (low, medium, high, native)', value: 'all' }, - ...Object.entries(PROFILES).map(([name, p]) => ({ - name: `${name.padEnd(10)} ${p.label}`, - value: name, - })), - { name: '(custom) Custom network/device settings', value: 'custom' }, - ]; - - const { profiles } = await inquirer.prompt([ - { - type: 'checkbox', - name: 'profiles', - message: 'Simulation profile(s):', - choices: profileChoices, - validate: (v) => v.length > 0 || 'Select at least one option', - }, - ]); - - let selectedProfiles = profiles; - if (selectedProfiles.includes('all')) { - selectedProfiles = [...new Set([...Object.keys(PROFILES), ...selectedProfiles.filter((p) => p === 'custom')])]; - } + // Interactive profile selection: only when no profile/network/device flags were given + if (resolved.runs.length === 0) { + assertTTY(); + const profileChoices = [ + { name: 'all Run all profiles (low, medium, high, native)', value: 'all' }, + ...Object.entries(PROFILES).map(([name, p]) => ({ + name: `${name.padEnd(10)} ${p.label}`, + value: name, + })), + { name: '(custom) Custom network/device settings', value: 'custom' }, + ]; + + const { profiles } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'profiles', + message: 'Simulation profile(s):', + choices: profileChoices, + validate: (v) => v.length > 0 || 'Select at least one option', + }, + ]); - let customNetwork; - let customDevice; - if (selectedProfiles.includes('custom')) { - const networkChoices = Object.entries(NETWORK_PRESETS).map(([name, n]) => ({ - name: `${name.padEnd(10)} ${n.label}`, - value: name, - })); - networkChoices.unshift({ name: '(default) Lighthouse default throttling', value: '' }); + let selectedProfiles = profiles; + if (selectedProfiles.includes('all')) { + selectedProfiles = [...new Set([...Object.keys(PROFILES), ...selectedProfiles.filter((p) => p === 'custom')])]; + } - const deviceChoices = Object.entries(DEVICE_PRESETS).map(([name, d]) => ({ - name: `${name.padEnd(16)} ${d.label}`, - value: name, - })); - deviceChoices.unshift({ name: '(default) Lighthouse default device', value: '' }); + let customNetwork; + let customDevice; + if (selectedProfiles.includes('custom')) { + const networkChoices = Object.entries(NETWORK_PRESETS).map(([name, n]) => ({ + name: `${name.padEnd(10)} ${n.label}`, + value: name, + })); + networkChoices.unshift({ name: '(default) Lighthouse default throttling', value: '' }); + + const deviceChoices = Object.entries(DEVICE_PRESETS).map(([name, d]) => ({ + name: `${name.padEnd(16)} ${d.label}`, + value: name, + })); + deviceChoices.unshift({ name: '(default) Lighthouse default device', value: '' }); - const answers = await inquirer.prompt([ - { type: 'list', name: 'network', message: 'Network throttling:', choices: networkChoices }, - { type: 'list', name: 'device', message: 'Device emulation:', choices: deviceChoices }, - ]); - customNetwork = answers.network || undefined; - customDevice = answers.device || undefined; - } + const answers = await inquirer.prompt([ + { type: 'list', name: 'network', message: 'Network throttling:', choices: networkChoices }, + { type: 'list', name: 'device', message: 'Device emulation:', choices: deviceChoices }, + ]); + customNetwork = answers.network || undefined; + customDevice = answers.device || undefined; + } - for (const p of selectedProfiles) { - if (p === 'custom') { - resolved.runs.push({ profile: undefined, network: customNetwork, device: customDevice }); - } else { - resolved.runs.push({ profile: p, network: undefined, device: undefined }); + for (const p of selectedProfiles) { + if (p === 'custom') { + resolved.runs.push({ profile: undefined, network: customNetwork, device: customDevice }); + } else { + resolved.runs.push({ profile: p, network: undefined, device: undefined }); + } } } - // Skip audits selection (only in interactive mode, not when --skip-audits flag is used) + // Skip audits selection — prompt if not provided via --skip-audits flag if (!options.skipAudits) { + assertTTY(); const skipAuditChoices = SKIPPABLE_AUDITS.map((a) => ({ name: a.id, value: a.id, @@ -332,8 +333,9 @@ async function promptLab(url, options) { resolved.skipAudits = skipAudits; } - // Blocked URL patterns (only in interactive mode, not when --blocked-url-patterns flag is used) + // Blocked URL patterns — prompt if not provided via --blocked-url-patterns flag if (!options.blockedUrlPatterns) { + assertTTY(); const { blockedUrlPatternsInput } = await inquirer.prompt([ { type: 'input', @@ -347,6 +349,7 @@ async function promptLab(url, options) { } if (options.stripJsonProps === undefined) { + assertTTY(); const { stripJsonProps } = await inquirer.prompt([ { type: 'confirm', @@ -433,11 +436,10 @@ async function promptPsi(url, options) { } resolved.urls = normalizeUrls(urls); - // Resolve categories: flag > prompt + // Resolve categories: flag > prompt > default (all) if (options.category) { resolved.categories = options.category.split(',').map((c) => c.trim().toUpperCase().replace(/-/g, '_')); - } else { - assertTTY(); + } else if (process.stdin.isTTY) { const { categories } = await inquirer.prompt([ { type: 'checkbox', @@ -632,10 +634,10 @@ async function promptCruxHistory(url, options) { } async function promptSitemap(url, options) { - assertTTY(); const resolved = { url, depth: options.depth, delay: options.delay, outputAi: options.outputAi || false }; if (!resolved.url) { + assertTTY(); const answers = await inquirer.prompt([ { type: 'input', name: 'url', message: 'Domain or sitemap URL (e.g. example.com or example.com/sitemap-pages.xml):', validate: validateUrl }, ]); @@ -643,49 +645,57 @@ async function promptSitemap(url, options) { } if (resolved.depth == null) { - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'depth', - message: 'Max recursion depth (Enter = 3):', - validate: validatePositiveInt, - }, - ]); - resolved.depth = answers.depth.trim() ? parseInt(answers.depth, 10) : 3; + if (process.stdin.isTTY) { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'depth', + message: 'Max recursion depth (Enter = 3):', + validate: validatePositiveInt, + }, + ]); + resolved.depth = answers.depth.trim() ? parseInt(answers.depth, 10) : 3; + } else { + resolved.depth = 3; + } } if (resolved.delay == null) { - const answers = await inquirer.prompt([ - { - type: 'input', - name: 'delay', - message: 'Delay between requests in ms (Enter = no delay):', - validate: validateNonNegativeInt, - }, - ]); - resolved.delay = answers.delay.trim() ? parseInt(answers.delay, 10) : undefined; + if (process.stdin.isTTY) { + const answers = await inquirer.prompt([ + { + type: 'input', + name: 'delay', + message: 'Delay between requests in ms (Enter = no delay):', + validate: validateNonNegativeInt, + }, + ]); + resolved.delay = answers.delay.trim() ? parseInt(answers.delay, 10) : undefined; + } } if (!resolved.outputAi) { - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'outputAi', - message: 'Generate AI-friendly output?', - default: false, - }, - ]); - resolved.outputAi = answers.outputAi; + if (process.stdin.isTTY) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'outputAi', + message: 'Generate AI-friendly output?', + default: false, + }, + ]); + resolved.outputAi = answers.outputAi; + } } return resolved; } async function promptLinks(url, options = {}) { - assertTTY(); const resolved = { url, outputAi: options.outputAi || false }; if (!resolved.url) { + assertTTY(); const answers = await inquirer.prompt([ { type: 'input', name: 'url', message: 'URL to extract links from:', validate: validateUrl }, ]); @@ -693,15 +703,17 @@ async function promptLinks(url, options = {}) { } if (!resolved.outputAi) { - const answers = await inquirer.prompt([ - { - type: 'confirm', - name: 'outputAi', - message: 'Generate AI-friendly output?', - default: false, - }, - ]); - resolved.outputAi = answers.outputAi; + if (process.stdin.isTTY) { + const answers = await inquirer.prompt([ + { + type: 'confirm', + name: 'outputAi', + message: 'Generate AI-friendly output?', + default: false, + }, + ]); + resolved.outputAi = answers.outputAi; + } } return resolved; diff --git a/lib/prompts.test.js b/lib/prompts.test.js index dd474ea..48d036e 100644 --- a/lib/prompts.test.js +++ b/lib/prompts.test.js @@ -105,6 +105,14 @@ describe('promptLinks', () => { const result = await prompts.promptLinks('https://example.com', {}); expect(result.outputAi).toBe(true); }); + + it('should work in non-TTY when url is provided, defaulting outputAi to false', async () => { + process.stdin.isTTY = false; + const result = await prompts.promptLinks('https://example.com'); + expect(result.url).toBe('https://example.com'); + expect(result.outputAi).toBe(false); + expect(promptSpy).not.toHaveBeenCalled(); + }); }); describe('parseProfileFlag', () => { @@ -188,15 +196,18 @@ describe('promptLab', () => { const baseOpts = { profile: undefined, network: undefined, device: undefined, urls: undefined, urlsFile: undefined }; it('should return runs array with single profile when provided via flag', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low' }); - expect(result).toEqual({ - urls: ['https://example.com/'], - runs: [{ profile: 'low', network: undefined, device: undefined }], - }); - expect(promptSpy).not.toHaveBeenCalled(); + expect(result.urls).toEqual(['https://example.com/']); + expect(result.runs).toEqual([{ profile: 'low', network: undefined, device: undefined }]); }); it('should return multiple runs for comma-separated profiles', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low,high' }); expect(result.runs).toEqual([ { profile: 'low', network: undefined, device: undefined }, @@ -205,12 +216,18 @@ describe('promptLab', () => { }); it('should expand "all" to 4 runs', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'all' }); expect(result.runs).toHaveLength(4); expect(result.runs.map((r) => r.profile)).toEqual(['low', 'medium', 'high', 'native']); }); it('should return custom run when only network/device flags provided', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, network: '3g', device: 'iphone-12' }); expect(result.runs).toEqual([{ profile: undefined, network: '3g', device: 'iphone-12' }]); }); @@ -268,6 +285,9 @@ describe('promptLab', () => { }); it('should resolve multiple URLs from --urls flag', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, { ...baseOpts, profile: 'low', urls: 'https://a.com, https://b.com, https://c.com' }); expect(result.urls).toEqual(['https://a.com/', 'https://b.com/', 'https://c.com/']); expect(result.runs).toHaveLength(1); @@ -275,18 +295,27 @@ describe('promptLab', () => { it('should resolve URLs from --urls-file flag', async () => { readFileSyncSpy.mockReturnValueOnce('https://a.com\nhttps://b.com\n\nhttps://c.com\n'); + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, { ...baseOpts, profile: 'low', urlsFile: '/path/to/urls.txt' }); expect(result.urls).toEqual(['https://a.com/', 'https://b.com/', 'https://c.com/']); expect(readFileSyncSpy).toHaveBeenCalledWith('/path/to/urls.txt', 'utf-8'); }); it('should ignore positional url when --urls is provided', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://ignored.com', { ...baseOpts, profile: 'low', urls: 'https://a.com,https://b.com' }); expect(result.urls).toEqual(['https://a.com/', 'https://b.com/']); }); it('should combine --urls and --urls-file', async () => { readFileSyncSpy.mockReturnValueOnce('https://c.com\nhttps://d.com\n'); + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, { ...baseOpts, profile: 'low', urls: 'https://a.com,https://b.com', urlsFile: '/path/to/urls.txt' }); expect(result.urls).toEqual(['https://a.com/', 'https://b.com/', 'https://c.com/', 'https://d.com/']); }); @@ -320,9 +349,11 @@ describe('promptLab', () => { }); it('should not prompt for skipAudits when --skip-audits flag is provided', async () => { + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', skipAudits: 'full-page-screenshot,final-screenshot' }); expect(result.skipAudits).toBeUndefined(); - expect(promptSpy).not.toHaveBeenCalled(); + expect(promptSpy).toHaveBeenCalledTimes(2); }); it('should return blockedUrlPatterns from interactive prompt', async () => { @@ -344,12 +375,17 @@ describe('promptLab', () => { }); it('should not prompt for blockedUrlPatterns when --blocked-url-patterns flag is provided', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', blockedUrlPatterns: '*.google-analytics.com' }); expect(result.blockedUrlPatterns).toBeUndefined(); - expect(promptSpy).not.toHaveBeenCalled(); + expect(promptSpy).toHaveBeenCalledTimes(2); }); it('should strip query strings and hashes from URLs', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, { ...baseOpts, profile: 'low', @@ -359,6 +395,9 @@ describe('promptLab', () => { }); it('should deduplicate URLs that differ only by query/hash', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); const result = await prompts.promptLab(undefined, { ...baseOpts, profile: 'low', @@ -386,15 +425,19 @@ describe('promptLab', () => { }); it('should propagate stripJsonProps: false from CLI flag path', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', stripJsonProps: false }); expect(result.stripJsonProps).toBe(false); - expect(promptSpy).not.toHaveBeenCalled(); + expect(promptSpy).toHaveBeenCalledTimes(2); }); it('should propagate stripJsonProps: true from CLI flag path', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', stripJsonProps: true }); expect(result.stripJsonProps).toBe(true); - expect(promptSpy).not.toHaveBeenCalled(); + expect(promptSpy).toHaveBeenCalledTimes(2); }); it('should prompt for stripJsonProps when options.stripJsonProps is undefined (interactive default)', async () => { @@ -406,6 +449,70 @@ describe('promptLab', () => { const result = await prompts.promptLab('https://example.com', { ...baseOpts, stripJsonProps: undefined }); expect(result.stripJsonProps).toBe(false); }); + + // Mixed flag+prompt scenarios: each option prompted only when its flag is absent + it('should prompt for skipAudits/blockedUrlPatterns/stripJsonProps when only --profile is given', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: ['full-page-screenshot'] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '*.ads.example.com' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: false }); + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low' }); + expect(result.runs).toEqual([{ profile: 'low', network: undefined, device: undefined }]); + expect(result.skipAudits).toEqual(['full-page-screenshot']); + expect(result.blockedUrlPatterns).toEqual(['*.ads.example.com']); + expect(result.stripJsonProps).toBe(false); + expect(promptSpy).toHaveBeenCalledTimes(3); + }); + + it('should prompt only for blockedUrlPatterns/stripJsonProps when --profile and --skip-audits are given', async () => { + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', skipAudits: 'full-page-screenshot' }); + expect(result.skipAudits).toBeUndefined(); + expect(result.blockedUrlPatterns).toBeUndefined(); + expect(result.stripJsonProps).toBe(true); + expect(promptSpy).toHaveBeenCalledTimes(2); + }); + + it('should prompt only for skipAudits/stripJsonProps when --profile and --blocked-url-patterns are given', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', blockedUrlPatterns: '*.ads.example.com' }); + expect(result.skipAudits).toEqual([]); + expect(result.blockedUrlPatterns).toBeUndefined(); + expect(result.stripJsonProps).toBe(true); + expect(promptSpy).toHaveBeenCalledTimes(2); + }); + + it('should prompt only for skipAudits/blockedUrlPatterns when --profile and --no-strip-json-props are given', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + const result = await prompts.promptLab('https://example.com', { ...baseOpts, profile: 'low', stripJsonProps: false }); + expect(result.skipAudits).toEqual([]); + expect(result.blockedUrlPatterns).toBeUndefined(); + expect(result.stripJsonProps).toBe(false); + expect(promptSpy).toHaveBeenCalledTimes(2); + }); + + it('should fire no prompts when all options are provided via flags', async () => { + const result = await prompts.promptLab('https://example.com', { + ...baseOpts, + profile: 'low', + skipAudits: 'full-page-screenshot', + blockedUrlPatterns: '*.ads.example.com', + stripJsonProps: true, + }); + expect(result.runs).toEqual([{ profile: 'low', network: undefined, device: undefined }]); + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it('should prompt for skipAudits/blockedUrlPatterns/stripJsonProps when --network flag is given', async () => { + promptSpy.mockResolvedValueOnce({ skipAudits: [] }); + promptSpy.mockResolvedValueOnce({ blockedUrlPatternsInput: '' }); + promptSpy.mockResolvedValueOnce({ stripJsonProps: true }); + const result = await prompts.promptLab('https://example.com', { ...baseOpts, network: '3g' }); + expect(result.runs).toEqual([{ profile: undefined, network: '3g', device: undefined }]); + expect(promptSpy).toHaveBeenCalledTimes(3); + }); }); describe('promptPsi', () => { @@ -559,6 +666,13 @@ describe('promptPsi', () => { expect(result.categories).toEqual(['PERFORMANCE']); expect(promptSpy).not.toHaveBeenCalled(); }); + + it('should default categories to undefined (all) in non-TTY when --category not provided', async () => { + process.stdin.isTTY = false; + const result = await prompts.promptPsi('https://example.com', { ...baseOptions, apiKey: 'k' }); + expect(result.categories).toBeUndefined(); + expect(promptSpy).not.toHaveBeenCalled(); + }); }); describe('promptCrux', () => { @@ -760,6 +874,21 @@ describe('promptSitemap', () => { const result = await prompts.promptSitemap('https://example.com', {}); expect(result.outputAi).toBe(true); }); + + it('should work in non-TTY when url is provided, using defaults for missing options', async () => { + process.stdin.isTTY = false; + const result = await prompts.promptSitemap('https://example.com', {}); + expect(result.url).toBe('https://example.com'); + expect(result.depth).toBe(3); + expect(result.delay).toBeUndefined(); + expect(result.outputAi).toBe(false); + expect(promptSpy).not.toHaveBeenCalled(); + }); + + it('should throw when not a TTY and url is missing', async () => { + process.stdin.isTTY = false; + await expect(prompts.promptSitemap(undefined, {})).rejects.toThrow('not a TTY'); + }); }); describe('validateUrl', () => {