Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/browser/command-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Command } from 'commander';
import { describe, expect, it } from 'vitest';
import { createProgram } from '../cli.js';
import { browserCommandCatalog, browserOptionValueParser } from './command-catalog.js';
import { browserCommandCatalog, browserOptionFlags, browserOptionValueParser } from './command-catalog.js';

function browserCommand(): Command {
const browser = createProgram('', '').commands.find(command => command.name() === 'browser');
Expand Down Expand Up @@ -66,6 +66,7 @@ describe('browserCommandCatalog', () => {
const commands = new Map(browserCommandCatalog.map(command => [command.command, command]));
expect(commands.get('bind')?.options).toEqual([
expect.objectContaining({ name: 'page', required: true }),
expect.objectContaining({ name: 'verbose', type: 'boolean' }),
]);
expect(commands.get('run')?.options.map(option => option.name)).toEqual([
'stdin',
Expand All @@ -74,13 +75,38 @@ describe('browserCommandCatalog', () => {
'maxOutput',
'snapshotMode',
'noSnapshotDiff',
'verbose',
]);
});

it('includes snapshot as the read-only browser inspection command', () => {
const snapshot = browserCommandCatalog.find(command => command.command === 'snapshot');
expect(snapshot).toMatchObject({ action: 'snapshot', sessionPolicy: 'require-existing' });
expect(snapshot?.options.map(option => option.name)).toEqual(['snapshotMode', 'ref', 'maxOutput']);
expect(snapshot?.options.map(option => option.name)).toEqual(['snapshotMode', 'ref', 'maxOutput', 'verbose']);
});

// Verbose belongs to the bridge/CDP leaves only: the diagnostics behind it live
// in the CDP client, so a filesystem-only leaf would be advertising a no-op (#174).
it('exposes verbose on bridge leaves and withholds it from filesystem-only ones', () => {
const optionNames = (name: string) => browserCommandCatalog
.find(command => command.command === name)
?.options.map(option => option.name) ?? [];

for (const leaf of ['tabs', 'bind', 'run', 'snapshot', 'close']) {
expect(optionNames(leaf)).toContain('verbose');
}
for (const leaf of ['init', 'fork', 'verify']) {
expect(optionNames(leaf)).not.toContain('verbose');
}
});

it('renders the verbose flag with its local short form', () => {
const verbose = browserCommandCatalog
.find(command => command.command === 'tabs')
?.options.find(option => option.name === 'verbose');

expect(verbose).toBeDefined();
expect(browserOptionFlags(verbose!, 'tabs')).toBe('-v, --verbose');
});

it('parses run snapshot mode as act or tree only', () => {
Expand Down
33 changes: 31 additions & 2 deletions src/browser/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,27 @@ function flag(name: string, description: string): HostedArgumentContract {
return { name, type: 'boolean', description, positional: false, required: false, variadic: false };
}

/**
* `-v` for a browser leaf that performs bridge/CDP I/O, whose diagnostics are
* already gated on `isVerbose()` (`src/browser/cdp.ts`).
*
* Declared here as well as in `cli.ts` because hosted help is rendered from this
* catalog while local help comes from Commander, and the two are asserted to be
* byte-identical. The explicit `false` default reproduces local's
* "(default: false)" suffix.
*/
function verboseFlag(): HostedArgumentContract {
return {
name: 'verbose',
type: 'boolean',
description: 'Debug output',
positional: false,
required: false,
variadic: false,
default: false,
};
}

function command(
commandPath: string,
description: string,
Expand Down Expand Up @@ -78,6 +99,7 @@ const adapterNamePositional: HostedArgumentContract = {
/** Exact local Commander flags for every catalogued browser option. */
export function browserOptionFlags(option: HostedArgumentContract, commandPath?: string): string {
const longName = option.name.replace(/[A-Z]/g, character => `-${character.toLowerCase()}`);
if (option.name === 'verbose') return '-v, --verbose';
if (option.type === 'boolean') return `--${longName}`;
const valueName = option.name === 'page' ? 'id'
: option.name === 'file' ? 'path'
Expand Down Expand Up @@ -119,10 +141,13 @@ export function browserOptionValueParser(
}

export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [
command('tabs', 'List pages in the existing browser session', 'tabs', [], [], 'require-existing'),
command('tabs', 'List pages in the existing browser session', 'tabs', [], [
verboseFlag(),
], 'require-existing'),
command('init', 'Generate an adapter scaffold', 'init', [adapterNamePositional], [], 'create-or-reuse'),
command('bind', 'Bind this session to an existing page', 'bind', [], [
option('page', 'Stable page id returned by tabs', { required: true }),
verboseFlag(),
], 'require-existing'),
command('fork', 'Fork an installed plugin command into a private copy', 'fork', [{
name: 'name',
Expand All @@ -148,11 +173,15 @@ export const browserCommandCatalog: readonly HostedBrowserCommandContract[] = [
option('maxOutput', 'Maximum returned characters'),
option('snapshotMode', 'Snapshot mode for automatic diff: act or tree', { default: 'act' }),
flag('noSnapshotDiff', 'Skip the automatic before/after snapshot diff'),
verboseFlag(),
], 'create-or-reuse'),
command('snapshot', 'Inspect the current page with a compact accessibility snapshot', 'snapshot', [], [
option('snapshotMode', 'Snapshot mode: act, tree, or read', { default: 'act' }),
option('ref', 'Render only the subtree rooted at this snapshot ref'),
option('maxOutput', 'Maximum returned characters'),
verboseFlag(),
], 'require-existing'),
command('close', 'Close or detach this browser session', 'close-window', [], [], 'close-existing'),
command('close', 'Close or detach this browser session', 'close-window', [], [
verboseFlag(),
], 'close-existing'),
] as const;
61 changes: 60 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1175,7 +1175,7 @@ name: 'search',
usage: 'webcmd browser bind [options]',
positionals: [],
});
expect(bind.command_options.map((option: any) => option.name)).toEqual(['page']);
expect(bind.command_options.map((option: any) => option.name)).toEqual(['page', 'verbose']);
expect(data.structured_help).toMatchObject({
formats: ['yaml', 'json'],
usage: 'webcmd browser --help -f yaml',
Expand Down Expand Up @@ -1797,6 +1797,65 @@ describe('browser raw session commands', () => {
});
});

// The CDP client already gates diagnostics on isVerbose(), but the raw browser
// leaves rejected -v, so those diagnostics were unreachable from the CLI (#174).
describe('raw browser verbose flag', () => {
afterEach(() => {
delete process.env.WEBCMD_VERBOSE;
});

it.each(['tabs', 'snapshot', 'close'])('accepts -v on browser %s', async (leaf) => {
delete process.env.WEBCMD_VERBOSE;
const program = createProgram('', '');

await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', leaf, '-v']);

expect(process.exitCode).toBeUndefined();
expect(process.env.WEBCMD_VERBOSE).toBe('1');
});

it('accepts -v on browser bind', async () => {
delete process.env.WEBCMD_VERBOSE;
const program = createProgram('', '');

await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123', '-v']);

expect(process.env.WEBCMD_VERBOSE).toBe('1');
expect(mockSendCommand).toHaveBeenCalledWith('bind', {
session: 'session_test', surface: 'browser', page: 'page-123',
});
});

it('leaves verbose mode off when the flag is absent', async () => {
delete process.env.WEBCMD_VERBOSE;
const program = createProgram('', '');

await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']);

expect(process.env.WEBCMD_VERBOSE).toBeUndefined();
});

// Structural check so `run` is covered too: it needs a program source, so it
// cannot reach the action body from argv alone.
//
// Filesystem-only leaves stay without the flag: there are no diagnostics
// behind it, and advertising one would be the no-op #174 set out to remove.
it('declares the flag on bridge leaves and withholds it from filesystem-only ones', () => {
const program = createProgram('', '');
const browser = program.commands.find(command => command.name() === 'browser');
const flagsFor = (name: string) => browser?.commands
.find(command => command.name() === name)
?.options.map(option => option.long) ?? [];

for (const leaf of ['tabs', 'bind', 'run', 'snapshot', 'close']) {
expect(flagsFor(leaf)).toContain('--verbose');
}
for (const leaf of ['init', 'fork']) {
expect(flagsFor(leaf)).not.toContain('--verbose');
}
});
});

it('binds raw browser daemon operations to one logical run', async () => {
let run = getDaemonRunContext();
mockSendCommand.mockImplementation(async () => {
Expand Down
38 changes: 26 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { analyzeSite, type PageSignals } from './browser/analyze.js';
import { browserOptionValueParser } from './browser/command-catalog.js';
import { registerAuthCommands } from './commands/auth.js';
import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
import { isVerbose, log } from './logger.js';
import { enableVerbose, isVerbose, log } from './logger.js';
import { BrowserCommandError, listExistingBrowserTabs, releaseSiteSessionLease, sendCommand } from './browser/daemon-client.js';
import { fetchDaemonStatus } from './browser/daemon-transport.js';
import { aliasForContextId, loadProfileConfig, profileListRows, profileRouteParams, renameProfile, resolveProfileSelection, setDefaultProfile, type ProfileSelection } from './browser/profile.js';
Expand Down Expand Up @@ -579,7 +579,20 @@ function sessionCreateOutput(data: unknown): unknown {
}

function applyVerbose(opts: { verbose?: boolean }): void {
if (opts.verbose) process.env.WEBCMD_VERBOSE = '1';
enableVerbose(opts.verbose === true);
}

/**
* Add `-v` to a browser leaf that performs bridge/CDP I/O.
*
* The CDP client already gates diagnostics on `isVerbose()`
* (`src/browser/cdp.ts`), but the raw browser leaves rejected the flag, so those
* diagnostics were only reachable by setting `WEBCMD_VERBOSE` by hand (#174).
* Filesystem-only leaves (`init`, `fork`) are deliberately left out: a flag
* there would advertise diagnostics that do not exist.
*/
function withBrowserVerbose(command: Command): Command {
return command.option('-v, --verbose', 'Debug output', false);
}

function formatChildCommandSummary(command: Command): string {
Expand Down Expand Up @@ -1142,6 +1155,7 @@ cli({

function rawBrowserAction(fn: (session: string, routing: { contextId?: string; preferredContextId?: string }, opts: Record<string, unknown>) => Promise<unknown>) {
return async (opts: Record<string, unknown>, command: Command) => {
applyVerbose(opts as { verbose?: boolean });
const runId = generateRunId();
const commandName = `browser/${command.name()}`;
let releaseRun = true;
Expand Down Expand Up @@ -1172,11 +1186,11 @@ cli({
};
}

browser.addCommand(new Command('tabs')
browser.addCommand(withBrowserVerbose(new Command('tabs')
.description('List pages in the existing browser session')
.action(rawBrowserAction((session, routing) => listExistingBrowserTabs(session, routing))));
.action(rawBrowserAction((session, routing) => listExistingBrowserTabs(session, routing)))));

browser.addCommand(new Command('bind')
browser.addCommand(withBrowserVerbose(new Command('bind')
.description('Bind this session to an existing page')
.addOption(new Option('--page <id>', 'Stable page id returned by tabs')
.makeOptionMandatory()
Expand All @@ -1185,16 +1199,16 @@ cli({
const page = typeof opts.page === 'string' ? opts.page.trim() : '';
if (!page) throw new BrowserCommandError('--page must be a non-empty stable page id', 'invalid_request');
return sendCommand('bind', { session, surface: 'browser', ...routing, page });
})));
}))));

const runCommand = new Command('run')
const runCommand = withBrowserVerbose(new Command('run')
.description('Run JavaScript with Playwright')
.option('--stdin', 'Read the program from stdin')
.option('--file <path>', 'Read the program from a file')
.addOption(new Option('--timeout <seconds>', 'Execution timeout in seconds').argParser(browserOptionValueParser('run', 'timeout')!))
.addOption(new Option('--max-output <characters>', 'Maximum returned characters').argParser(browserOptionValueParser('run', 'maxOutput')!))
.addOption(new Option('--snapshot-mode <mode>', 'Snapshot mode for automatic diff: act or tree').default('act').argParser(browserOptionValueParser('run', 'snapshotMode')!))
.option('--no-snapshot-diff', 'Skip the automatic before/after snapshot diff');
.option('--no-snapshot-diff', 'Skip the automatic before/after snapshot diff'));
runCommand.action(rawBrowserAction(async (session, routing, opts) => {
let source: string;
try {
Expand All @@ -1218,7 +1232,7 @@ cli({
}));
browser.addCommand(runCommand);

browser.addCommand(new Command('snapshot')
browser.addCommand(withBrowserVerbose(new Command('snapshot')
.description('Inspect the current page with a compact accessibility snapshot')
.addOption(new Option('--snapshot-mode <mode>', 'Snapshot mode: act, tree, or read').default('act').argParser(browserOptionValueParser('snapshot', 'snapshotMode')!))
.option('--ref <ref>', 'Render only the subtree rooted at this snapshot ref')
Expand All @@ -1230,15 +1244,15 @@ cli({
snapshotMode: opts.snapshotMode === 'tree' || opts.snapshotMode === 'read' ? opts.snapshotMode : 'act',
...(typeof opts.ref === 'string' ? { ref: opts.ref } : {}),
...(typeof opts.maxOutput === 'number' ? { maxOutputChars: opts.maxOutput } : {}),
}))));
})))));

browser.addCommand(new Command('close')
browser.addCommand(withBrowserVerbose(new Command('close')
.description('Close or detach this browser session')
.action(rawBrowserAction((session, routing) => sendCommand('close-window', {
session,
surface: 'browser',
...routing,
}))));
})))));
// ── Built-in: doctor / completion ──────────────────────────────────────────

program
Expand Down
32 changes: 31 additions & 1 deletion src/commands/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtemp, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BrowserCliCommand } from '../registry.js';

const executeCommandMock = vi.hoisted(() => vi.fn());
Expand Down Expand Up @@ -307,3 +307,33 @@ describe('auth command format validation', () => {
expect(executeCommandMock).not.toHaveBeenCalled();
});
});

// Both probes drive the browser/daemon stack, whose CDP diagnostics are already
// gated on isVerbose() — so -v has something observable behind it here (#174).
describe('auth verbose flag', () => {
afterEach(() => {
delete process.env.WEBCMD_VERBOSE;
});

it.each(['status', 'refresh'] as const)('turns on verbose mode for auth %s', async (subcommand) => {
delete process.env.WEBCMD_VERBOSE;
registerWhoami('alpha', { quick: true, quickLoggedIn: true });
const program = new Command('webcmd');
registerAuthCommands(program);

await program.parseAsync(['node', 'webcmd', 'auth', subcommand, '--site', 'alpha', '-v']);

expect(process.env.WEBCMD_VERBOSE).toBe('1');
});

it.each(['status', 'refresh'] as const)('leaves verbose mode off for auth %s without the flag', async (subcommand) => {
delete process.env.WEBCMD_VERBOSE;
registerWhoami('alpha', { quick: true, quickLoggedIn: true });
const program = new Command('webcmd');
registerAuthCommands(program);

await program.parseAsync(['node', 'webcmd', 'auth', subcommand, '--site', 'alpha']);

expect(process.env.WEBCMD_VERBOSE).toBeUndefined();
});
});
7 changes: 7 additions & 0 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Command, InvalidArgumentError, Option } from 'commander';
import { OUTPUT_FORMAT_HELP, resolveOutputFormat } from '../command-surface.js';
import { AuthRequiredError, CliError, getErrorMessage } from '../errors.js';
import { executeCommand } from '../execution.js';
import { enableVerbose } from '../logger.js';
import {
type BrowserCliCommand,
type CliCommand,
Expand Down Expand Up @@ -467,7 +468,11 @@ export function registerAuthCommands(program: Command): Command {
.option('--timeout <seconds>', 'Per-site timeout in seconds')
.addOption(new Option('--only <status>', 'Filter rows by status').choices(['all', 'logged-in', 'not-logged-in', 'unknown', 'error']).default('all'))
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table')
.option('-v, --verbose', 'Debug output', false)
.action(async (opts) => {
// Both auth probes drive the browser/daemon stack, so verbose mode surfaces
// the CDP diagnostics those layers already gate on `isVerbose()` (#174).
enableVerbose(opts.verbose === true);
const fmt = resolveOutputFormat(opts.format);
if (fmt === null) return;
const globals = typeof status.optsWithGlobals === 'function' ? status.optsWithGlobals() as Record<string, unknown> : {};
Expand Down Expand Up @@ -496,7 +501,9 @@ export function registerAuthCommands(program: Command): Command {
.option('--concurrency <n>', 'Maximum sites to refresh at once')
.option('--timeout <seconds>', 'Per-site timeout in seconds')
.option('-f, --format <fmt>', OUTPUT_FORMAT_HELP, 'table')
.option('-v, --verbose', 'Debug output', false)
.action(async (opts) => {
enableVerbose(opts.verbose === true);
const fmt = resolveOutputFormat(opts.format);
if (fmt === null) return;
const globals = typeof refresh.optsWithGlobals === 'function' ? refresh.optsWithGlobals() as Record<string, unknown> : {};
Expand Down
Loading
Loading