@vizzly-testing/cli
Version:
Visual review platform for UI developers and designers
466 lines (438 loc) • 20.3 kB
JavaScript
import 'dotenv/config';
import { program } from 'commander';
import { doctorCommand, validateDoctorOptions } from './commands/doctor.js';
import { finalizeCommand, validateFinalizeOptions } from './commands/finalize.js';
import { init } from './commands/init.js';
import { loginCommand, validateLoginOptions } from './commands/login.js';
import { logoutCommand, validateLogoutOptions } from './commands/logout.js';
import { projectListCommand, projectRemoveCommand, projectSelectCommand, projectTokenCommand, validateProjectOptions } from './commands/project.js';
import { runCommand, validateRunOptions } from './commands/run.js';
import { statusCommand, validateStatusOptions } from './commands/status.js';
import { tddCommand, validateTddOptions } from './commands/tdd.js';
import { runDaemonChild, tddStartCommand, tddStatusCommand, tddStopCommand } from './commands/tdd-daemon.js';
import { uploadCommand, validateUploadOptions } from './commands/upload.js';
import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js';
import { createPluginServices } from './plugin-api.js';
import { loadPlugins } from './plugin-loader.js';
import { createServices } from './services/index.js';
import { colors } from './utils/colors.js';
import { loadConfig } from './utils/config-loader.js';
import { getContext } from './utils/context.js';
import * as output from './utils/output.js';
import { getPackageVersion } from './utils/package-info.js';
// Custom help formatting with Observatory design system
const formatHelp = (cmd, helper) => {
let c = colors;
let lines = [];
let isRootCommand = !cmd.parent;
let version = getPackageVersion();
// Branded header with grizzly bear
lines.push('');
if (isRootCommand) {
// Cute grizzly bear mascot with square eyes (like the Vizzly logo!)
lines.push(c.brand.amber(' ʕ□ᴥ□ʔ'));
lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.dim(`v${version}`)}`);
lines.push(` ${c.gray('Visual regression testing for UI teams')}`);
} else {
// Compact header for subcommands
lines.push(` ${c.brand.amber(c.bold('vizzly'))} ${c.white(cmd.name())}`);
let desc = cmd.description();
if (desc) {
lines.push(` ${c.gray(desc)}`);
}
}
lines.push('');
// Usage
let usage = helper.commandUsage(cmd).replace('Usage: ', '');
lines.push(` ${c.dim('Usage')} ${c.white(usage)}`);
lines.push('');
// Get all subcommands
let commands = helper.visibleCommands(cmd);
if (commands.length > 0) {
if (isRootCommand) {
// Group commands by category for root help with icons
let categories = [{
key: 'core',
icon: '▸',
title: 'Core',
names: ['run', 'tdd', 'upload', 'status', 'finalize']
}, {
key: 'setup',
icon: '▸',
title: 'Setup',
names: ['init', 'doctor']
}, {
key: 'auth',
icon: '▸',
title: 'Account',
names: ['login', 'logout', 'whoami']
}, {
key: 'project',
icon: '▸',
title: 'Projects',
names: ['project:select', 'project:list', 'project:token', 'project:remove']
}];
let grouped = {
core: [],
setup: [],
auth: [],
project: [],
other: []
};
for (let command of commands) {
let name = command.name();
if (name === 'help') continue;
let found = false;
for (let cat of categories) {
if (cat.names.includes(name)) {
grouped[cat.key].push(command);
found = true;
break;
}
}
if (!found) grouped.other.push(command);
}
for (let cat of categories) {
let cmds = grouped[cat.key];
if (cmds.length === 0) continue;
lines.push(` ${c.brand.amber(cat.icon)} ${c.bold(cat.title)}`);
for (let command of cmds) {
let name = command.name();
let desc = command.description() || '';
// Truncate long descriptions
if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
}
lines.push('');
}
// Plugins (other commands from plugins)
if (grouped.other.length > 0) {
lines.push(` ${c.brand.amber('▸')} ${c.bold('Plugins')}`);
for (let command of grouped.other) {
let name = command.name();
let desc = command.description() || '';
if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
}
lines.push('');
}
} else {
// For subcommands, simple list
lines.push(` ${c.brand.amber('▸')} ${c.bold('Commands')}`);
for (let command of commands) {
let name = command.name();
if (name === 'help') continue;
let desc = command.description() || '';
if (desc.length > 48) desc = `${desc.substring(0, 45)}...`;
lines.push(` ${c.white(name.padEnd(18))} ${c.gray(desc)}`);
}
lines.push('');
}
}
// Options - use dimmer styling for less visual weight
let options = helper.visibleOptions(cmd);
if (options.length > 0) {
lines.push(` ${c.brand.amber('▸')} ${c.bold('Options')}`);
for (let option of options) {
let flags = option.flags;
let desc = option.description || '';
if (desc.length > 40) desc = `${desc.substring(0, 37)}...`;
lines.push(` ${c.cyan(flags.padEnd(22))} ${c.dim(desc)}`);
}
lines.push('');
}
// Quick start examples (only for root command)
if (isRootCommand) {
lines.push(` ${c.brand.amber('▸')} ${c.bold('Quick Start')}`);
lines.push('');
lines.push(` ${c.dim('# Local visual testing')}`);
lines.push(` ${c.gray('$')} ${c.white('vizzly tdd start')}`);
lines.push('');
lines.push(` ${c.dim('# CI pipeline')}`);
lines.push(` ${c.gray('$')} ${c.white('vizzly run "npm test" --wait')}`);
lines.push('');
}
// Dynamic context section (only for root)
if (isRootCommand) {
let contextItems = getContext();
if (contextItems.length > 0) {
lines.push(` ${c.dim('─'.repeat(52))}`);
for (let item of contextItems) {
if (item.type === 'success') {
lines.push(` ${c.green('✓')} ${c.gray(item.label)} ${c.white(item.value)}`);
} else if (item.type === 'warning') {
lines.push(` ${c.yellow('!')} ${c.gray(item.label)} ${c.yellow(item.value)}`);
} else {
lines.push(` ${c.dim('○')} ${c.gray(item.label)} ${c.dim(item.value)}`);
}
}
lines.push('');
}
}
// Footer with links
lines.push(` ${c.dim('─'.repeat(52))}`);
lines.push(` ${c.dim('Docs')} ${c.cyan(c.underline('docs.vizzly.dev'))} ${c.dim('GitHub')} ${c.cyan(c.underline('github.com/vizzly-testing/cli'))}`);
lines.push('');
return lines.join('\n');
};
program.name('vizzly').description('Vizzly CLI for visual regression testing').version(getPackageVersion()).option('-c, --config <path>', 'Config file path').option('--token <token>', 'Vizzly API token').option('-v, --verbose', 'Verbose output (shorthand for --log-level debug)').option('--log-level <level>', 'Log level: debug, info, warn, error (default: info, or VIZZLY_LOG_LEVEL env var)').option('--json', 'Machine-readable output').option('--color', 'Force colored output (even in non-TTY)').option('--no-color', 'Disable colored output').configureHelp({
formatHelp
});
// Load plugins before defining commands
// We need to manually parse to get the config option early
let configPath = null;
let verboseMode = false;
let logLevelArg = null;
for (let i = 0; i < process.argv.length; i++) {
if ((process.argv[i] === '-c' || process.argv[i] === '--config') && process.argv[i + 1]) {
configPath = process.argv[i + 1];
}
if (process.argv[i] === '-v' || process.argv[i] === '--verbose') {
verboseMode = true;
}
if (process.argv[i] === '--log-level' && process.argv[i + 1]) {
logLevelArg = process.argv[i + 1];
}
}
// Configure output early
// Priority: --log-level > --verbose > VIZZLY_LOG_LEVEL env var > default ('info')
// Color priority: --no-color (off) > --color (on) > auto-detect
let colorOverride;
if (process.argv.includes('--no-color')) {
colorOverride = false;
} else if (process.argv.includes('--color')) {
colorOverride = true;
}
output.configure({
logLevel: logLevelArg,
verbose: verboseMode,
color: colorOverride,
json: process.argv.includes('--json')
});
const config = await loadConfig(configPath, {});
const services = createServices(config);
const pluginServices = createPluginServices(services);
let plugins = [];
try {
plugins = await loadPlugins(configPath, config);
for (const plugin of plugins) {
try {
// Add timeout protection for plugin registration (5 seconds)
const registerPromise = plugin.register(program, {
config,
services: pluginServices,
output,
// Backwards compatibility alias for plugins using old API
logger: output
});
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin registration timeout (5s)')), 5000));
await Promise.race([registerPromise, timeoutPromise]);
output.debug(`Registered plugin: ${plugin.name}`);
} catch (error) {
output.warn(`Failed to register plugin ${plugin.name}: ${error.message}`);
}
}
} catch (error) {
output.debug(`Plugin loading failed: ${error.message}`);
}
program.command('init').description('Initialize Vizzly in your project').option('--force', 'Overwrite existing configuration').action(async options => {
const globalOptions = program.opts();
await init({
...globalOptions,
...options,
plugins
});
});
program.command('upload').description('Upload screenshots to Vizzly').argument('<path>', 'Path to screenshots directory or file').option('-b, --build-name <name>', 'Build name for grouping').option('-m, --metadata <json>', 'Additional metadata as JSON').option('--batch-size <n>', 'Upload batch size', v => parseInt(v, 10)).option('--upload-timeout <ms>', 'Upload timeout in milliseconds', v => parseInt(v, 10)).option('--branch <branch>', 'Git branch').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (path, options) => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateUploadOptions(path, options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await uploadCommand(path, options, globalOptions);
});
// TDD command with subcommands - Local visual testing with interactive dashboard
const tddCmd = program.command('tdd').description('Run tests in TDD mode with local visual comparisons');
// TDD Start - Background server
tddCmd.command('start').description('Start background TDD server with dashboard').option('--port <port>', 'Port for TDD server', '47392').option('--open', 'Open dashboard in browser').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--token <token>', 'API token override').option('--daemon-child', 'Internal: run as daemon child process').action(async options => {
const globalOptions = program.opts();
// If this is a daemon child process, run the server directly
if (options.daemonChild) {
await runDaemonChild(options, globalOptions);
return;
}
await tddStartCommand(options, globalOptions);
});
// TDD Stop - Kill background server
tddCmd.command('stop').description('Stop background TDD server').action(async options => {
const globalOptions = program.opts();
await tddStopCommand(options, globalOptions);
});
// TDD Status - Check server status
tddCmd.command('status').description('Check TDD server status').action(async options => {
const globalOptions = program.opts();
await tddStatusCommand(options, globalOptions);
});
// TDD Run - One-off test run with ephemeral server (generates static report)
tddCmd.command('run <command>').description('Run tests once in TDD mode with local visual comparisons').option('--port <port>', 'Port for TDD server', '47392').option('--branch <branch>', 'Git branch override').option('--environment <env>', 'Environment name', 'test').option('--threshold <number>', 'Comparison threshold', parseFloat).option('--token <token>', 'API token override').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--baseline-build <id>', 'Use specific build as baseline').option('--baseline-comparison <id>', 'Use specific comparison as baseline').option('--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)').action(async (command, options) => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateTddOptions(command, options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
const {
result,
cleanup
} = await tddCommand(command, options, globalOptions);
// Set up cleanup on process signals
const handleCleanup = async () => {
await cleanup();
};
process.once('SIGINT', () => {
handleCleanup().then(() => process.exit(1));
});
process.once('SIGTERM', () => {
handleCleanup().then(() => process.exit(1));
});
if (result && !result.success && result.exitCode > 0) {
await cleanup();
process.exit(result.exitCode);
}
await cleanup();
});
program.command('run').description('Run tests with Vizzly integration').argument('<command>', 'Test command to run').option('--port <port>', 'Port for screenshot server', '47392').option('-b, --build-name <name>', 'Custom build name').option('--branch <branch>', 'Git branch override').option('--commit <sha>', 'Git commit SHA').option('--message <msg>', 'Commit message').option('--environment <env>', 'Environment name', 'test').option('--token <token>', 'API token override').option('--wait', 'Wait for build completion').option('--timeout <ms>', 'Server timeout in milliseconds', '30000').option('--allow-no-token', 'Allow running without API token').option('--upload-all', 'Upload all screenshots without SHA deduplication').option('--parallel-id <id>', 'Unique identifier for parallel test execution').action(async (command, options) => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateRunOptions(command, options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
try {
const result = await runCommand(command, options, globalOptions);
if (result && !result.success && result.exitCode > 0) {
process.exit(result.exitCode);
}
} catch (error) {
output.error('Command failed', error);
process.exit(1);
}
});
program.command('status').description('Check the status of a build').argument('<build-id>', 'Build ID to check status for').action(async (buildId, options) => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateStatusOptions(buildId, options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await statusCommand(buildId, options, globalOptions);
});
program.command('finalize').description('Finalize a parallel build after all shards complete').argument('<parallel-id>', 'Parallel ID to finalize').action(async (parallelId, options) => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateFinalizeOptions(parallelId, options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await finalizeCommand(parallelId, options, globalOptions);
});
program.command('doctor').description('Run diagnostics to check your environment and configuration').option('--api', 'Include API connectivity checks').action(async options => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateDoctorOptions(options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await doctorCommand(options, globalOptions);
});
program.command('login').description('Authenticate with your Vizzly account').option('--api-url <url>', 'API URL override').action(async options => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateLoginOptions(options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await loginCommand(options, globalOptions);
});
program.command('logout').description('Clear stored authentication tokens').option('--api-url <url>', 'API URL override').action(async options => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateLogoutOptions(options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await logoutCommand(options, globalOptions);
});
program.command('whoami').description('Show current authentication status and user information').option('--api-url <url>', 'API URL override').action(async options => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateWhoamiOptions(options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await whoamiCommand(options, globalOptions);
});
program.command('project:select').description('Configure project for current directory').option('--api-url <url>', 'API URL override').action(async options => {
const globalOptions = program.opts();
// Validate options
const validationErrors = validateProjectOptions(options);
if (validationErrors.length > 0) {
output.error('Validation errors:');
for (let error of validationErrors) {
output.printErr(` - ${error}`);
}
process.exit(1);
}
await projectSelectCommand(options, globalOptions);
});
program.command('project:list').description('Show all configured projects').action(async options => {
const globalOptions = program.opts();
await projectListCommand(options, globalOptions);
});
program.command('project:token').description('Show project token for current directory').action(async options => {
const globalOptions = program.opts();
await projectTokenCommand(options, globalOptions);
});
program.command('project:remove').description('Remove project configuration for current directory').action(async options => {
const globalOptions = program.opts();
await projectRemoveCommand(options, globalOptions);
});
program.parse();