From c605330aeee33545e28242929f6e04a5a758e674 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Thu, 14 May 2026 17:29:39 +0200 Subject: [PATCH] Make --help easier to navigate with topic-filtered views Browsertime --help dumps every option from all 13 topic groups in one screen, which is overwhelming for new users and slow to skim. Adopt the curl/ffmpeg pattern: show a small curated subset by default, let users drill into one topic at a time, and keep the full reference one flag away for scripts and power users. browsertime --help now shows a short list of common options plus the available topic names. browsertime --help shows just that topic, and browsertime --help-all reproduces the historical full dump for anyone relying on it. Each option already carries an inline group: '' field, so the dispatcher captures the topic-to-keys map by wrapping yargs's .option() call -- no changes to the 1400-line option chain. Co-authored-by: Claude noreply@anthropic.com Change-Id: I5d79c91617c5c9a06497b8306deff854c83ec5e2 --- lib/support/cli.js | 61 +++++++---- lib/support/helpTopics.js | 174 +++++++++++++++++++++++++++++++ test/unittests/helpTopicsTest.js | 90 ++++++++++++++++ 3 files changed, 305 insertions(+), 20 deletions(-) create mode 100644 lib/support/helpTopics.js create mode 100644 test/unittests/helpTopicsTest.js diff --git a/lib/support/cli.js b/lib/support/cli.js index 38f64be10..fbf65707d 100644 --- a/lib/support/cli.js +++ b/lib/support/cli.js @@ -14,6 +14,12 @@ import { geckoProfilerDefaults } from '../firefox/settings/geckoProfilerDefaults import { findUpSync } from './fileUtil.js'; import { setProperty, getProperty } from './util.js'; import { execaSync } from 'execa'; +import { + classifyHelp, + captureTopics, + applyHelpFilter, + buildEpilog +} from './helpTopics.js'; const configPath = findUpSync(['.browsertime.json']); @@ -152,7 +158,16 @@ export function parseCommandLine() { arg === '--android' ? '--android.enabled' : arg ); - let yargsInstance = yargs(hideBin(argvFix)); + // Detect --help / --help-all up front and rewrite them to a + // bare --help so yargs still triggers its normal help+exit flow. + const rawArgs = hideBin(argvFix); + const help = classifyHelp(rawArgs); + + let yargsInstance = yargs(help.args); + // Wrap .option() to record each option's key under its inline + // `group: ''` field. Browsertime already annotates every option + // with a group, so this gives us a topic -> keys map for free. + const topicKeys = captureTopics(yargsInstance); let validated = yargsInstance .parserConfiguration({ 'camel-case-expansion': false, @@ -923,76 +938,76 @@ export function parseCommandLine() { type: 'boolean', default: false, describe: 'Save one screenshot per iteration.', - group: 'Screenshot' + group: 'screenshot' }) .option('screenshotLCP', { type: 'boolean', default: false, describe: 'Save one screenshot per iteration that shows the largest contentful paint element (if the browser supports LCP).', - group: 'Screenshot' + group: 'screenshot' }) .option('screenshotLS', { type: 'boolean', default: false, describe: 'Save one screenshot per iteration that shows the layout shift elements (if the browser supports layout shift).', - group: 'Screenshot' + group: 'screenshot' }) .option('screenshotParams.type', { describe: 'Set the file type of the screenshot', choices: ['png', 'jpg'], default: screenshotDefaults.type, - group: 'Screenshot' + group: 'screenshot' }) .option('screenshotParams.jpg.quality', { describe: 'Quality of the JPEG screenshot. 1-100', default: screenshotDefaults.jpg.quality, - group: 'Screenshot' + group: 'screenshot' }) .option('screenshotParams.maxSize', { describe: 'The max size of the screenshot (width and height).', default: screenshotDefaults.maxSize, - group: 'Screenshot' + group: 'screenshot' }) .option('pageCompleteCheck', { describe: 'Supply a JavaScript (inline or JavaScript file) that decides when the browser is finished loading the page and can start to collect metrics. The JavaScript snippet is repeatedly queried to see if page has completed loading (indicated by the script returning true). Use it to fetch timings happening after the loadEventEnd. By default the tests ends 2 seconds after loadEventEnd. Also checkout --pageCompleteCheckInactivity and --pageCompleteCheckPollTimeout', - group: 'PageLoad' + group: 'pageload' }) .option('pageCompleteWaitTime', { describe: 'How long time you want to wait for your pageComplteteCheck to finish, after it is signaled to closed. Extra parameter passed on to your pageCompleteCheck.', default: 8000, - group: 'PageLoad' + group: 'pageload' }) .option('pageCompleteCheckInactivity', { describe: 'Alternative way to choose when to end your test. This will wait for 2 seconds of inactivity that happens after loadEventEnd.', type: 'boolean', default: false, - group: 'PageLoad' + group: 'pageload' }) .option('pageCompleteCheckNetworkIdle', { describe: 'Alternative way to choose when to end your test that works in Chrome and Firefox. Uses CDP or WebDriver Bidi to look at network traffic instead of running JavaScript in the browser to know when to end the test. By default this will wait 5 seconds of inactivity in the network log (no requets/responses in 5 seconds). Use --timeouts.networkIdle to change the 5 seconds. The test will end after 2 minutes if there is still activity on the network. You can change that timout using --timeouts.pageCompleteCheck ', type: 'boolean', default: false, - group: 'PageLoad' + group: 'pageload' }) .option('pageCompleteCheckPollTimeout', { type: 'number', default: 1500, describe: 'The time in ms to wait for running the page complete check the next time.', - group: 'PageLoad' + group: 'pageload' }) .option('pageCompleteCheckStartWait', { type: 'number', default: 5000, describe: 'The time in ms to wait for running the page complete check for the first time. Use this when you have a pageLoadStrategy set to none', - group: 'PageLoad' + group: 'pageload' }) .option('pageLoadStrategy', { type: 'string', @@ -1000,7 +1015,7 @@ export function parseCommandLine() { choices: ['eager', 'none', 'normal'], describe: 'Set the strategy to waiting for document readiness after a navigation event. After the strategy is ready, your pageCompleteCheck will start running.', - group: 'PageLoad' + group: 'pageload' }) .option('iterations', { alias: 'n', @@ -1025,14 +1040,14 @@ export function parseCommandLine() { default: 0, describe: 'Extra time added for the browser to settle before starting to test a URL. This delay happens after the browser was opened and before the navigation to the URL', - group: 'PageLoad' + group: 'pageload' }) .option('webdriverPageload', { type: 'boolean', describe: 'Use webdriver.get to initialize the page load instead of window.location.', default: false, - group: 'PageLoad' + group: 'pageload' }) .option('proxy.pac', { type: 'string', @@ -1168,7 +1183,7 @@ export function parseCommandLine() { 'Use internal browser functionality to clear browser cache between runs instead of only using Selenium.', type: 'boolean', default: false, - group: 'PageLoad' + group: 'pageload' }) .option('basicAuth', { describe: @@ -1310,7 +1325,7 @@ export function parseCommandLine() { default: false, describe: 'Flush DNS between runs, works on Mac OS and Linux. Your user needs sudo rights to be able to flush the DNS.', - group: 'PageLoad' + group: 'pageload' }) .option('extension', { describe: @@ -1321,7 +1336,7 @@ export function parseCommandLine() { 'Convenient parameter to use if you test a SPA application: will automatically wait for X seconds after last network activity and use hash in file names. Read more: https://www.sitespeed.io/documentation/sitespeed.io/spa/', type: 'boolean', default: false, - group: 'PageLoad' + group: 'pageload' }) .option('cjs', { describe: @@ -1334,7 +1349,7 @@ export function parseCommandLine() { default: 3, describe: 'If the browser fails to start, you can retry to start it this amount of times.', - group: 'PageLoad' + group: 'pageload' }) .option('preWarmServer', { type: 'boolean', @@ -1363,8 +1378,14 @@ export function parseCommandLine() { .alias('V', 'version') .alias('v', 'verbose') .wrap(yargsInstance.terminalWidth()) + .epilog(buildEpilog(help.mode, help.topic, [...topicKeys.keys()])) .check(validateInput); + // Filter the visible options based on the requested help mode. This + // must happen before .argv is read because that's what triggers + // yargs to render help and exit. + applyHelpFilter(yargsInstance, help.mode, help.topic, topicKeys); + let argv = validated.argv; if ( diff --git a/lib/support/helpTopics.js b/lib/support/helpTopics.js new file mode 100644 index 000000000..297f79dba --- /dev/null +++ b/lib/support/helpTopics.js @@ -0,0 +1,174 @@ +/*eslint no-console: 0*/ + +// Help-topic dispatch for the Browsertime CLI. +// +// Browsertime declares ~250 options in one big yargs chain, with each +// option carrying an inline `group: ''` field. The unfiltered +// --help dump is overwhelming; this module lets users say: +// +// browsertime --help -> curated set of common options +// browsertime --help -> only that topic +// browsertime --help-all -> the full historical dump +// +// We intercept the yargs instance's .option() call to capture each +// key's topic from the inline `group` field, so the cli.js chain +// doesn't have to change shape. + +// Curated set of "everyday" options the default --help should show. +// Keep this list short -- anything missing is one --help away. +const COMMON_KEYS = new Set([ + // common everyday flags + 'browser', + 'b', + 'iterations', + 'n', + 'video', + 'visualMetrics', + 'speedIndex', + 'docker', + 'preURL', + 'headless', + 'connectivity.profile', + 'c', + 'connectivity.engine', + 'mobile', + 'resultDir', + 'cpu', + // meta + 'config', + 'help', + 'h', + 'verbose', + 'v', + 'version', + 'V' +]); + +// Meta-flags that always stay visible regardless of mode. +const ALWAYS_KEEP = new Set([ + 'help', + 'h', + 'version', + 'V', + 'config', + 'verbose', + 'v' +]); + +const DOCS_URL = 'https://www.sitespeed.io/documentation/browsertime/'; + +// Parse the raw arg list, detect help intent, and return a cleaned arg +// list with `--help-all` / `--help ` collapsed to a plain +// `--help` so yargs still triggers its normal help+exit flow. +// +// Returns { mode, topic, args } where mode is one of: +// 'none' -> no --help passed +// 'default' -> plain --help, show the curated common subset +// 'topic' -> --help , show only that topic +// 'all' -> --help-all (or --help all), show everything +export function classifyHelp(rawArgs) { + const cleaned = []; + let mode = 'none'; + let topic; + for (let i = 0; i < rawArgs.length; i++) { + const a = rawArgs[i]; + if (a === '--help-all') { + mode = 'all'; + cleaned.push('--help'); + continue; + } + if (a === '--help' || a === '-h' || a === '--help=true') { + const next = rawArgs[i + 1]; + if (next && !next.startsWith('-')) { + if (next === 'all') { + mode = 'all'; + i++; + } else { + mode = 'topic'; + topic = next; + i++; + } + } else if (mode === 'none') { + mode = 'default'; + } + cleaned.push('--help'); + continue; + } + cleaned.push(a); + } + return { mode, topic, args: cleaned }; +} + +// Wrap a yargs instance so every subsequent .option(key, def) call also +// records `key` under `def.group` in the returned Map. Browsertime's +// option chain already carries inline `group: ''` annotations, +// so no chain edits are needed. +export function captureTopics(yargsInstance) { + const topicKeys = new Map(); + const originalOption = yargsInstance.option.bind(yargsInstance); + yargsInstance.option = function (key, def) { + if (def && def.group) { + const list = topicKeys.get(def.group) || []; + list.push(key); + topicKeys.set(def.group, list); + } + return originalOption(key, def); + }; + return topicKeys; +} + +// Build the epilog (footer) shown under --help, varying by mode. +export function buildEpilog(mode, topic, topicNames) { + if (mode === 'all' || mode === 'none') { + return `Read the docs at ${DOCS_URL}`; + } + if (mode === 'topic') { + return [ + `Showing only the "${topic}" options.`, + `Run with --help-all for the full reference, or --help to see common options.`, + `Read the docs at ${DOCS_URL}` + ].join('\n'); + } + // default + const topics = topicNames.join(', '); + return [ + 'Topics (use `browsertime --help `):', + ' ' + topics, + '', + 'Run with --help-all to see every option, or read the docs at ' + DOCS_URL + ].join('\n'); +} + +// Apply the topic filter by calling yargs.hide() on every key not in +// the set we want to display. Returns true if filtering was applied. +export function applyHelpFilter(yargsInstance, mode, topic, topicKeys) { + if (mode === 'none' || mode === 'all') return false; + + let keep; + if (mode === 'topic') { + if (!topicKeys.has(topic)) { + console.error(`Unknown help topic: ${topic}`); + console.error('Available topics: ' + [...topicKeys.keys()].join(', ')); + // CLI dispatch: bailing out here is intentional (deliberate "no + // such topic" exit code), not an error to bubble up the stack. + // eslint-disable-next-line unicorn/no-process-exit + process.exit(2); + } + // Topic mode = just the topic. No COMMON_KEYS floor here -- topic + // views should focus on the topic, not re-render common stuff + // that's one --help away. + keep = new Set(topicKeys.get(topic)); + } else { + // default + keep = COMMON_KEYS; + } + + const allKeys = Object.keys(yargsInstance.getOptions().key); + for (const k of allKeys) { + if (ALWAYS_KEEP.has(k)) continue; + if (!keep.has(k)) { + yargsInstance.hide(k); + } + } + return true; +} diff --git a/test/unittests/helpTopicsTest.js b/test/unittests/helpTopicsTest.js new file mode 100644 index 000000000..47d6d295f --- /dev/null +++ b/test/unittests/helpTopicsTest.js @@ -0,0 +1,90 @@ +import test from 'ava'; +import yargs from 'yargs'; + +import { + classifyHelp, + captureTopics, + applyHelpFilter, + buildEpilog +} from '../../lib/support/helpTopics.js'; + +test('classifyHelp recognises a plain --help', t => { + const result = classifyHelp(['--help']); + t.is(result.mode, 'default'); + t.deepEqual(result.args, ['--help']); +}); + +test('classifyHelp recognises --help and strips the topic', t => { + const result = classifyHelp(['--help', 'chrome']); + t.is(result.mode, 'topic'); + t.is(result.topic, 'chrome'); + t.deepEqual(result.args, ['--help']); +}); + +test('classifyHelp recognises --help-all', t => { + const result = classifyHelp(['--help-all']); + t.is(result.mode, 'all'); + t.deepEqual(result.args, ['--help']); +}); + +test('classifyHelp recognises --help all (space form)', t => { + const result = classifyHelp(['--help', 'all']); + t.is(result.mode, 'all'); + t.deepEqual(result.args, ['--help']); +}); + +test('classifyHelp returns none when --help is not present', t => { + const result = classifyHelp(['https://example.com', '-n', '5']); + t.is(result.mode, 'none'); + t.is(result.topic, undefined); + t.deepEqual(result.args, ['https://example.com', '-n', '5']); +}); + +test('classifyHelp does not eat the next arg when it looks like a flag', t => { + const result = classifyHelp(['--help', '--mobile']); + t.is(result.mode, 'default'); + t.deepEqual(result.args, ['--help', '--mobile']); +}); + +test('captureTopics records keys under their inline group field', t => { + const y = yargs([]); + const topics = captureTopics(y); + y.option('chrome.foo', { group: 'chrome' }) + .option('chrome.bar', { group: 'chrome' }) + .option('firefox.x', { group: 'firefox' }) + .option('without.group', { describe: 'no group' }); + t.deepEqual(topics.get('chrome'), ['chrome.foo', 'chrome.bar']); + t.deepEqual(topics.get('firefox'), ['firefox.x']); + t.false(topics.has('without.group')); +}); + +test('applyHelpFilter hides everything outside the requested topic', t => { + const y = yargs([]); + const topics = captureTopics(y); + y.option('mobile', { type: 'boolean' }) + .option('config', { type: 'string' }) + .option('chrome.args', { type: 'string', group: 'chrome' }) + .option('s3.bucketname', { type: 'string', group: 's3' }); + + applyHelpFilter(y, 'topic', 'chrome', topics); + + const opts = y.getOptions(); + // The requested topic stays. + t.false(opts.hiddenOptions.includes('chrome.args')); + // Other topics are hidden. + t.true(opts.hiddenOptions.includes('s3.bucketname')); + // Universal meta-flag stays visible. + t.false(opts.hiddenOptions.includes('config')); +}); + +test('buildEpilog mentions the topic list in default mode', t => { + const epilog = buildEpilog('default', undefined, ['chrome', 's3']); + t.true(epilog.includes('chrome')); + t.true(epilog.includes('s3')); +}); + +test('buildEpilog mentions the active topic in topic mode', t => { + const epilog = buildEpilog('topic', 'chrome', ['chrome']); + t.true(epilog.includes('chrome')); + t.true(epilog.includes('--help-all')); +});