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 + } } 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/bin/web-perf.js b/bin/web-perf.js index f86d736..2a2e444 100755 --- a/bin/web-perf.js +++ b/bin/web-perf.js @@ -4,16 +4,18 @@ 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; const totalUrls = resolved.urls.length; const totalRuns = totalUrls * resolved.runs.length; @@ -43,7 +45,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 +388,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 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. diff --git a/lib/lab.js b/lib/lab.js index 1113fed..9a0957b 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -5,6 +5,7 @@ const lighthouse = require('lighthouse').default; const logger = require('./logger'); const { resolveProfileSettings } = require('./profiles'); const { SKIPPABLE_AUDITS } = require('./prompts'); +const { stripJsonProps } = require('./strip-props'); const { ensureCommandDir, buildFilename } = require('./utils'); // Audits skipped by default — derived from SKIPPABLE_AUDITS to avoid duplication @@ -24,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'); @@ -57,7 +73,12 @@ 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); + if (labOptions.stripJsonProps !== false) { + const reportObj = stripJsonProps(JSON.parse(result.report)); + fs.writeFileSync(outputPath, JSON.stringify(reportObj, null, 2)); + } else { + fs.writeFileSync(outputPath, result.report); + } return outputPath; } finally { @@ -67,19 +88,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 }; diff --git a/lib/prompts.js b/lib/prompts.js index 95a3fd3..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,68 +252,70 @@ async function promptLab(url, options) { } else { resolved.runs.push({ profile: undefined, network: options.network || undefined, device: options.device || undefined }); } - 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, @@ -331,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', @@ -345,6 +348,21 @@ async function promptLab(url, options) { } } + if (options.stripJsonProps === undefined) { + assertTTY(); + 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; } @@ -418,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', @@ -617,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 }, ]); @@ -628,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 }, ]); @@ -678,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 58d3081..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' }]); }); @@ -220,6 +237,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 +246,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 +255,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 +266,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 +276,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 }, @@ -263,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); @@ -270,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/']); }); @@ -291,6 +325,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 +334,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,20 +343,24 @@ 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']); }); 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 () => { 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,17 +369,23 @@ 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(); }); 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', @@ -349,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', @@ -356,6 +405,114 @@ 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 () => { + 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).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).toHaveBeenCalledTimes(2); + }); + + 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); + }); + + // 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', () => { @@ -509,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', () => { @@ -710,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', () => { 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); + }); +});