UNPKG

apiveritas

Version:

Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.

430 lines (429 loc) 17.1 kB
#!/usr/bin/env node "use strict"; /** * @file cli.ts * @author Mario Galea * @description * Entry point for the ApiVeritas CLI tool. * Provides a set of commands for API contract testing, comparison, reporting, and configuration. * */ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const commander_1 = require("commander"); const ConfigLoader_1 = require("../core/config/ConfigLoader"); const ApiCaller_1 = require("../core/services/ApiCaller"); const PayloadComparer_1 = require("../PayloadComparer"); const Logger_1 = require("../core/utils/Logger"); const path_1 = __importDefault(require("path")); const PackageInfo_1 = require("../core/utils/PackageInfo"); const TestSuiteLoader_1 = require("../core/services/TestSuiteLoader"); const chalk_1 = __importDefault(require("chalk")); const MockServer_1 = require("../core/services/MockServer"); const ExitHandler_1 = require("../core/services/ExitHandler"); const InitCommand_1 = require("../commands/InitCommand"); const program = new commander_1.Command(); const version = PackageInfo_1.PackageInfo.getInstance().getVersion(); const logger = new Logger_1.Logger(); const exitHandler = new ExitHandler_1.ExitHandler(logger); const configLoader = new ConfigLoader_1.ConfigLoader(); program .name('apiveritas') .description('\nA lightweight CLI tool for API contract testing') .version(version) .showHelpAfterError('(add --help for additional info)'); let config; try { config = configLoader.loadConfig(); } catch (err) { exitHandler.configError('❌ Failed to load configuration. Ensure config.json exists and is valid.'); } program .command('init') .description('Initialize ApiVeritas folder structure with template files') .option('--force', 'Overwrite existing files if present') .action(async (options) => { try { const initCmd = new InitCommand_1.InitCommand(logger); await initCmd.run(options.force === true); exitHandler.success(); } catch (error) { logger.error(`Init command failed: ${error instanceof Error ? error.message : error}`); process.exit(1); } }); /** * Run API tests by executing requests defined in a test suite. * Saves responses to timestamped payload folders. * Supports mock server mode. * * @example * // Run tests with a given test suite JSON file * $ apiveritas test --tests bookings.json * * @example * // Run tests in mock server mode (mock.json is forced) * $ apiveritas test --tests mock.json */ program .command('test') .description('Run all API requests and save responses') .requiredOption('--tests <file>', 'Specify the test suite JSON file') .action(async (options, command) => { let testFile = options.tests; if (config.enableMockServer) { logger.info('\n Mock server mode enabled.'); if (options.tests && options.tests !== 'mock.json') { logger.warn(`Ignoring passed test file "${options.tests}" — using "mock.json" due to enableMockServer=true`); } testFile = 'mock.json'; } else if (!testFile) { exitHandler.invalidArgs('❌ Missing required option: --tests <file>'); } let mockServer; if (config.enableMockServer) { try { mockServer = new MockServer_1.MockServer(); await mockServer.start(); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk_1.default.red(`❌ Failed to start mock server: ${message}`)); exitHandler.mockServerError(); } } let requests; try { requests = TestSuiteLoader_1.TestSuiteLoader.loadSuite(testFile, config); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk_1.default.red(`❌ Failed to load test suite: ${testFile}`)); logger.error(chalk_1.default.red(`-> ${message}`)); if (mockServer) await mockServer.stop(); exitHandler.testSuiteLoadingError(); return; } const caller = new ApiCaller_1.ApiCaller(requests, logger, config.baseUrl); try { await caller.callAll(); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk_1.default.red(`❌ Error during API execution: ${message}`)); if (mockServer) await mockServer.stop(); exitHandler.apiCallFailure(); } if (mockServer) await mockServer.stop(); exitHandler.success(); }); /** * List all JSON test suite files in the `tests/` directory. * * @example * $ apiveritas list-tests */ program .command('list-tests') .description('List all available JSON test files in the tests/ folder') .action(() => { try { const testFiles = TestSuiteLoader_1.TestSuiteLoader.listAvailableSuites(); if (testFiles.length === 0) { logger.info(chalk_1.default.yellow('⚠️ No test files found in the tests/ directory.\n')); exitHandler.testSuiteLoadingError(); } logger.info(chalk_1.default.green('✅ Available test suites:\n')); testFiles.forEach((file) => { logger.info(` - ${chalk_1.default.white(file)}`); }); console.log(); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error(chalk_1.default.red(`❌ Failed to list test suites.`)); logger.error(chalk_1.default.red(`-> ${message}`)); exitHandler.generalError(); } }); /** * Show the current path where payloads are stored. * * @example * $ apiveritas payloads-path */ program .command('payloads-path') .description('Show where the payloads are stored') .action(() => { try { const payloadsPath = config.payloadsPath; if (!payloadsPath) { exitHandler.configError('❌ Payloads path is not defined in the configuration.'); } logger.info(chalk_1.default.cyan('\n Payloads storage: ') + chalk_1.default.white(payloadsPath) + '\n'); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while retrieving payloads path.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Show the current path where HTML reports are stored. * * @example * $ apiveritas reports-path */ program .command('reports-path') .description('Show where HTML reports are stored') .action(() => { try { const reportsPath = config.reportsPath; if (!reportsPath) { exitHandler.configError('❌ Reports path is not defined in the configuration.'); } logger.info(chalk_1.default.cyan('\n Reports storage: ') + chalk_1.default.white(reportsPath) + '\n'); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while retrieving reports path.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Display the current loaded configuration from `config.json`. * * @example * $ apiveritas config */ program .command('config') .description('Show current configuration loaded from config.json') .action(() => { try { // Create ConfigLoader instance (default folder 'apiveritas' in cwd) const configLoader = new ConfigLoader_1.ConfigLoader(); const config = configLoader.loadConfig(); if (!config || Object.keys(config).length === 0) { exitHandler.configError('❌ Configuration is empty or not loaded properly.'); } const configPath = path_1.default.join(process.cwd(), 'apiveritas', 'config.json'); logger.info(chalk_1.default.white('\n Configuration file: ') + chalk_1.default.white.bold(configPath) + '\n'); const maxKeyLength = Math.max(...Object.keys(config).map(key => key.length)); Object.entries(config).forEach(([key, value]) => { const paddedKey = key.padEnd(maxKeyLength, ' '); logger.info(`${chalk_1.default.white(paddedKey)} : ${chalk_1.default.green.bold(String(value))}`); }); console.log(); // For spacing exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred while displaying configuration.'; exitHandler.generalError(`❌ ${message}`); } }); /** * Update configuration values interactively. * * @example * // Enable strict schema validation and update base URL * $ apiveritas set-config --strictSchema true --baseUrl https://api.example.com * * @example * // Disable mock server mode * $ apiveritas set-config --enableMockServer false */ program .command('set-config') .description('Update one or more config values') .option('--strictSchema <boolean>', 'Enable or disable strict schema validation (true/false)') .option('--strictValues <boolean>', 'Enable or disable strict values validation (true/false)') .option('--tolerateEmptyResponses <boolean>', 'Enable or disable tolerance for empty responses (true/false)') .option('--payloadsPath <path>', 'Set a new path for payload storage') .option('--reportsPath <path>', 'Set a new path for reports') .option('--baseUrl <url>', 'Set the base URL for API calls') .option('--enableMockServer <boolean>', 'Run the Application in Mock Server Mode (tests/mock.json). All responses are sent to http://mockserver:3000') .action((options) => { try { const changes = {}; if (options.strictSchema !== undefined) { changes.strictSchema = options.strictSchema === 'true'; } if (options.strictValues !== undefined) { changes.strictValues = options.strictValues === 'true'; } if (options.payloadsPath !== undefined) { changes.payloadsPath = options.payloadsPath; } if (options.reportsPath !== undefined) { changes.reportsPath = options.reportsPath; } if (options.tolerateEmptyResponses !== undefined) { changes.tolerateEmptyResponses = options.tolerateEmptyResponses === 'true'; } if (options.baseUrl !== undefined) { changes.baseUrl = options.baseUrl; } if (options.enableMockServer !== undefined) { changes.enableMockServer = options.enableMockServer === 'true'; } if (Object.keys(changes).length === 0) { exitHandler.invalidArgs('⚠️ No configuration changes provided. Use set-config --help for available options.'); } const configLoader = new ConfigLoader_1.ConfigLoader(); configLoader.updateConfig(changes); logger.info(chalk_1.default.green('\n✅ Configuration updated successfully!\n')); exitHandler.success(); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error while updating configuration.'; exitHandler.configError(`❌ ${message}`); } }); /** * Compare two latest payload folders for a given test suite. * Reports schema/value differences. * * @example * $ apiveritas compare --testSuite bookings */ program .command('compare') .description('Compare the two most recent payload folders and show test results') .requiredOption('--testSuite <name>', 'Name of the test suite folder to compare') .action((options) => { try { const testSuite = options.testSuite; if (!testSuite) { exitHandler.invalidArgs('❌ Missing required option: --testSuite <name>'); } const comparer = new PayloadComparer_1.PayloadComparer(testSuite, config, logger); const folders = comparer.getLatestTwoPayloadFolders(testSuite); if (!folders) { logger.error(chalk_1.default.red('❌ Could not find two payload folders to compare.\n')); logger.info(chalk_1.default.yellow('-> Make sure you have at least two payload runs saved in your payloads directory.\n')); exitHandler.comparisonFailure('Insufficient payload folders for comparison.'); return; } const [oldFolder, newFolder] = folders; const isDifferent = comparer.compareFolders(oldFolder, newFolder, testSuite); if (isDifferent) { exitHandler.comparisonFailure('❌ Differences detected in payload comparison.'); } else { exitHandler.success('✅ Payload comparison completed with no differences.'); } } catch (err) { const message = err instanceof Error ? err.message : 'Unknown comparison error occurred.'; exitHandler.comparisonFailure(`❌ ${message}`); } }); /** * Run a full workflow: * - Execute API calls from a test suite * - Save responses * - Compare latest payload folders for differences * * @example * $ apiveritas run --tests bookings.json --testSuite bookings */ program .command('run') .description('Run tests, compare payloads, and report results') .requiredOption('--tests <file>', 'Specify the test suite JSON file') .requiredOption('--testSuite <name>', 'Name of the test suite folder to compare') .action(async (options, command) => { let testFile = options.tests; const testSuite = options.testSuite; if (config.enableMockServer) { logger.info('\n Mock server mode enabled.'); if (options.tests && options.tests !== 'mock.json') { logger.warn(`Ignoring passed test file "${options.tests}" — using "mock.json" due to enableMockServer=true`); } testFile = 'mock.json'; } if (!testFile) { logger.error(chalk_1.default.red('❌ Missing required option: --tests <file>\n')); command.help({ error: true }); exitHandler.invalidArgs('Missing required --tests option'); } if (!testSuite) { logger.error(chalk_1.default.red('❌ Missing required option: --testSuite <name>\n')); command.help({ error: true }); exitHandler.invalidArgs('Missing required --testSuite option'); } logger.info(chalk_1.default.cyan('\nRunning full test and comparison...\n')); let mockServer; if (config.enableMockServer) { mockServer = new MockServer_1.MockServer(); await mockServer.start(); } let requests; // force definite assignment later try { const loadedRequests = TestSuiteLoader_1.TestSuiteLoader.loadSuite(testFile, config); if (!loadedRequests) { throw new Error(`Test suite ${testFile} returned undefined or null`); } requests = loadedRequests; } catch (err) { logger.error(chalk_1.default.red(`❌ Failed to load test suite: ${testFile}\n`)); logger.error(chalk_1.default.red('-> Make sure the file exists and contains valid JSON.\n')); if (mockServer) await mockServer.stop(); exitHandler.testSuiteLoadingError(`Failed to load test suite file: ${testFile}`); return; // just for TS to know exitHandler probably exits } const caller = new ApiCaller_1.ApiCaller(requests, logger, config.baseUrl); await caller.callAll(); const comparer = new PayloadComparer_1.PayloadComparer(testSuite, config, logger); const folders = comparer.getLatestTwoPayloadFolders(); if (!folders) { logger.error(chalk_1.default.red('❌ Could not find two payload folders to compare.\n')); logger.info(chalk_1.default.yellow('-> Make sure you have at least two payload runs saved in your payloads directory.\n')); if (mockServer) await mockServer.stop(); exitHandler.comparisonFailure('Insufficient payload folders for comparison.'); return; // TS safety } // At this point, folders is guaranteed non-null with two strings const [oldFolder, newFolder] = folders; comparer.compareFolders(oldFolder, newFolder, testSuite); if (mockServer) await mockServer.stop(); exitHandler.success('Run, compare, and report completed successfully.'); }); /** * A fun easter egg inspired by Pulp Fiction. * * @example * $ apiveritas notest */ program .command('notest') .description('A little surprise inspired by Pulp Fiction') .action(() => { console.log(chalk_1.default.bold.yellow(`\n"Say 'what' again. I dare you, I double dare you..."`)); console.log(chalk_1.default.gray('— Jules Winnfield, Pulp Fiction\n')); console.log(chalk_1.default.cyanBright('Just remember, contractual integrity matters. Always.\n')); }); /** * Handler for unknown commands. */ program.on('command:*', (operands) => { logger.error(chalk_1.default.red(`❌ Unknown command: ${operands.join(' ')}\n`)); program.help({ error: true }); }); program.parse(process.argv);