dlest
Version:
Jest for your data layer - test runner for analytics tracking implementations
287 lines (250 loc) ⢠9.58 kB
JavaScript
const { Command } = require('commander');
const { Commands } = require('./commands');
const { ServerCommand } = require('./server-command');
const chalk = require('chalk');
const path = require('path');
/**
* CLI Runner
*
* Main CLI interface for DLest
*/
class CLIRunner {
constructor() {
this.program = new Command();
this.commands = new Commands();
this.serverCommand = new ServerCommand();
this.setupCommands();
}
/**
* Setup CLI commands
*/
setupCommands() {
this.program
.name('dlest')
.description('Jest for your data layer - test runner for analytics tracking')
.version(this.getVersion());
// Main run command (default)
this.program
.argument('[files...]', 'Test files to run or URL to test')
.option('--config <path>', 'Path to config file')
.option('--browser <browser>', 'Browser to use (chromium, firefox, webkit)', 'chromium')
.option('--headless', 'Run in headless mode', true)
.option('--no-headless', 'Run in headed mode')
.option('--timeout <ms>', 'Test timeout in milliseconds', '30000')
.option('--verbose', 'Verbose output')
.option('--watch', 'Watch mode (not implemented yet)')
.option('--serve', 'Auto-start development server before tests')
.option('--serve-port <port>', 'Port for development server', '3000')
.option('--serve-root <path>', 'Root directory for development server')
.option('--auth-user <username>', 'Basic auth username for remote testing')
.option('--auth-pass <password>', 'Basic auth password for remote testing')
.option('--test <file>', 'Specific test file to run (for remote testing)')
.option('--ci', 'CI mode (no colors, proper exit codes)')
.action(async (files, options) => {
let server = null;
// Configure CI mode
if (options.ci) {
chalk.level = 0; // Disable colors
process.env.CI = 'true';
}
try {
// Check if first argument is a URL
const isRemoteTest = files.length > 0 && this.isValidUrl(files[0]);
if (isRemoteTest) {
// Remote testing mode
const remoteUrl = files[0];
console.log(chalk.cyan(`š Remote testing mode: ${remoteUrl}\n`));
// Set up remote testing options
options.remoteUrl = remoteUrl;
options.testFiles = options.test ? [options.test] : [];
// Basic auth
if (options.authUser && options.authPass) {
options.auth = {
username: options.authUser,
password: options.authPass
};
}
} else if (options.serve) {
// Start local server if --serve option is provided
console.log(chalk.cyan('š Starting development server for tests...\n'));
const serverOptions = {
port: parseInt(options.servePort) || 3000,
root: options.serveRoot || process.cwd(),
verbose: false, // Keep server quiet during tests
};
const serverResult = await this.serverCommand.serveForTests(serverOptions);
server = this.serverCommand;
// Update base URL in test config if not already set
if (!options.config) {
options.baseURL = serverResult.url;
}
}
// Run tests
const result = await this.commands.run({
testFiles: isRemoteTest ? options.testFiles : files,
browser: options.browser,
headless: options.headless,
timeout: parseInt(options.timeout),
verbose: options.verbose,
config: options.config,
baseURL: options.baseURL,
remoteUrl: options.remoteUrl,
auth: options.auth,
ci: options.ci,
});
// Stop server if it was started
if (server) {
console.log(chalk.gray('\nā¹ļø Stopping development server...'));
await server.stop();
}
process.exit(result.success ? 0 : 1);
} catch (error) {
// Ensure server is stopped on error
if (server) {
await server.stop();
}
console.error(chalk.red('ā Error running tests:'));
console.error(chalk.red(error.message));
process.exit(1);
}
});
// Init command
this.program
.command('init')
.description('Initialize DLest in current project')
.option('--template <type>', 'Template to use (minimal, basic, spa, gtm, ecommerce)', 'basic')
.option('--with-fixtures', 'Include HTML fixture files (for static pages)')
.option('--force', 'Overwrite existing files')
.action(async (options) => {
const result = await this.commands.init(options);
process.exit(result.success ? 0 : 1);
});
// Install command
this.program
.command('install')
.description('Install Playwright browsers')
.action(async (options) => {
const result = await this.commands.install(options);
process.exit(result.success ? 0 : 1);
});
// Serve command
this.program
.command('serve')
.description('Start development server')
.option('-p, --port <port>', 'Port to run server on', '3000')
.option('-r, --root <path>', 'Root directory to serve', process.cwd())
.option('-h, --host <host>', 'Host to bind server to', 'localhost')
.option('-v, --verbose', 'Verbose server logs')
.action(async (options) => {
// Validate options
const errors = ServerCommand.validateOptions(options);
if (errors.length > 0) {
console.error(chalk.red('ā Invalid options:'));
errors.forEach(error => console.error(chalk.red(` - ${error}`)));
process.exit(1);
}
// Convert port to number
if (options.port) {
options.port = parseInt(options.port, 10);
if (isNaN(options.port)) {
console.error(chalk.red('ā Port must be a number'));
process.exit(1);
}
}
// Start server and wait
await this.serverCommand.serveAndWait(options);
});
// Version command (handled by commander automatically)
// Help customization
this.program.on('--help', () => {
console.log('');
console.log('Examples:');
console.log(' $ dlest # Run all tests');
console.log(' $ dlest tests/specific.test.js # Run specific test');
console.log(' $ dlest --browser=firefox # Use Firefox');
console.log(' $ dlest --no-headless # Run with GUI');
console.log(' $ dlest --serve # Auto-start server + run tests');
console.log(' $ dlest serve # Start development server');
console.log(' $ dlest serve --port 8080 # Server on custom port');
console.log(' $ dlest init # Initialize project');
console.log(' $ dlest init --template=ecommerce # Init with e-commerce template');
console.log('');
console.log('Configuration:');
console.log(' Create dlest.config.js in your project root for custom settings.');
console.log('');
console.log('Documentation:');
console.log(' https://github.com/metricasboss/dlest');
});
}
/**
* Run CLI
*/
async run(args = process.argv) {
try {
await this.program.parseAsync(args);
} catch (error) {
console.error(chalk.red('ā CLI Error:'));
console.error(chalk.red(error.message));
if (error.stack) {
console.error(chalk.gray(error.stack));
}
process.exit(1);
}
}
/**
* Check if string is a valid URL
*/
isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch (_) {
return false;
}
}
/**
* Get version from package.json
*/
getVersion() {
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageJson = require(packagePath);
return packageJson.version || '0.1.0';
} catch (error) {
return '0.1.0';
}
}
/**
* Handle uncaught errors
*/
setupErrorHandlers() {
process.on('uncaughtException', (error) => {
console.error(chalk.red('š„ Uncaught Exception:'));
console.error(chalk.red(error.message));
console.error(chalk.gray(error.stack));
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
console.error(chalk.red('š„ Unhandled Rejection:'));
console.error(chalk.red(reason));
// Debug info
if (reason && reason.stack) {
console.error(chalk.gray('\nStack trace:'));
console.error(chalk.gray(reason.stack));
}
console.error(chalk.gray('\nPromise:'), promise);
process.exit(1);
});
// Handle SIGINT (Ctrl+C)
process.on('SIGINT', () => {
console.log(chalk.yellow('\\nā¹ļø Interrupted by user'));
process.exit(0);
});
// Handle SIGTERM
process.on('SIGTERM', () => {
console.log(chalk.yellow('\\nā¹ļø Terminated'));
process.exit(0);
});
}
}
module.exports = { CLIRunner };