apiveritas
Version:
Lightweight CLI tool for consumer-driven API contract testing via JSON schema and payload comparisons.
289 lines (286 loc) • 13.5 kB
JavaScript
"use strict";
/**
* @file PayloadComparer.ts
* @author Mario Galea
* @description
* The PayloadComparer class compares JSON payload files between two snapshot folders
* representing different states of API responses. It supports schema generation,
* structural comparison, JSON Schema validation, and reports differences.
* The comparison results are logged and an HTML report is generated.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PayloadComparer = void 0;
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
const generate_schema_1 = __importDefault(require("generate-schema"));
const HtmlReporter_1 = require("./core/reporters/HtmlReporter");
const BasicComparator_1 = require("./core/comparators/BasicComparator");
const SchemaValidator_1 = require("./core/schema/SchemaValidator");
const SchemaStrictifier_1 = require("./core/schema/SchemaStrictifier");
const Logger_1 = require("./core/utils/Logger");
const chalk_1 = __importDefault(require("chalk"));
const ConfigLoader_1 = require("./core/config/ConfigLoader");
/**
* Compares JSON payloads between two folders and reports differences.
* Supports strict schema validation, value strictness, empty response tolerance,
* and mock server overrides via config.
*/
class PayloadComparer {
/**
* Creates a new PayloadComparer instance.
* @param {IComparerOptions} [options] - Options to control comparison behavior.
* @param {boolean} [options.strictSchema=true] - Enforce strict JSON schema validation.
* @param {boolean} [options.strictValues=true] - Enforce strict value matching.
* @param {boolean} [options.tolerateEmptyResponses=false] - Allow empty or missing payloads without failing.
* @param {Logger} [logger] - Optional logger instance to capture logs.
*/
constructor(testSuiteName, options = {
strictSchema: true,
strictValues: true,
tolerateEmptyResponses: false,
}, logger = new Logger_1.Logger()) {
this.configLoader = new ConfigLoader_1.ConfigLoader();
this.config = this.configLoader.loadConfig();
// Use custom or default payloads path
const basePayloadsDir = this.config.payloadsPath || path_1.default.join(process.cwd(), 'apiveritas', 'payloads');
// Include test suite in the path
this.payloadsDir = path_1.default.join(basePayloadsDir, testSuiteName);
this.options = options;
this.comparator = new BasicComparator_1.BasicComparator(this.options.strictValues);
this.logger = logger;
logger.info(chalk_1.default.white.bold.underline('Payload Comparison:\n'));
logger.info(`${chalk_1.default.white('Test Suite Folder:')} ${chalk_1.default.white(testSuiteName)}`);
logger.info(`${chalk_1.default.white('Payload Directory:')} ${chalk_1.default.white(this.payloadsDir)}\n`);
}
/**
* Retrieves the two most recent payload snapshot folders for comparison.
* Folders are sorted by name descending (newest assumed last).
* @returns {[string, string] | null} - Tuple of [previousFolder, latestFolder] or null if insufficient data.
getLatestTwoPayloadFolders(): [string, string] | null {
if (!fs.existsSync(this.payloadsDir)) {
this.logger.warn('No payloads directory found.');
return null;
}
/**
* Returns the names of the latest two timestamp folders which contain the given testSuite folder.
* If no testSuite specified, returns the latest two timestamp folders.
*/
getLatestTwoPayloadFolders(testSuite) {
if (!fs_1.default.existsSync(this.payloadsDir)) {
this.logger.warn('No payloads directory found.');
return null;
}
// Read all timestamp folders sorted newest first
const timestampFolders = fs_1.default
.readdirSync(this.payloadsDir)
.filter((file) => fs_1.default.statSync(path_1.default.join(this.payloadsDir, file)).isDirectory())
.sort()
.reverse();
if (timestampFolders.length < 2) {
this.logger.warn('Not enough payload folders to compare.');
return null;
}
// No testSuite specified, just return latest two timestamp folders
return [timestampFolders[0], timestampFolders[1]];
}
/**
* Compares JSON payload files in two folders (optionally within a test suite subfolder).
* Generates detailed logs, counts matched and differing files, validates schema,
* and generates an HTML report.
* @param {string} oldFolder - The folder name of the baseline payload snapshot.
* @param {string} newFolder - The folder name of the new payload snapshot.
* @param {string} [testSuite] - Optional test suite folder name within each snapshot folder.
* If mock server is enabled in config, this will be overridden to 'mock'.
* @returns {void}
*/
compareFolders(newFolder, oldFolder, testSuite) {
if (this.config.enableMockServer) {
testSuite = 'mock';
}
const startTime = Date.now();
this.logger.info(`${chalk_1.default.white('Present Prod :')} ${chalk_1.default.white(oldFolder)}`);
this.logger.info(`${chalk_1.default.white('Future Prod :')} ${chalk_1.default.white(newFolder)}`);
if (testSuite) {
this.logger.info(`${chalk_1.default.white('Test Suite :')} ${chalk_1.default.white(testSuite)}\n`);
}
else {
this.logger.info(chalk_1.default.yellowBright('! No test suite specified. Comparing top-level payload files.\n'));
}
if (!testSuite) {
this.logger.warn('No test suite specified. Cannot compare folders.');
return false;
}
const oldPath = path_1.default.join(this.payloadsDir, oldFolder);
const newPath = path_1.default.join(this.payloadsDir, newFolder);
if (!fs_1.default.existsSync(oldPath)) {
this.logger.warn(`Old folder path does not exist: ${oldPath}`);
return false;
}
if (!fs_1.default.existsSync(newPath)) {
this.logger.warn(`New folder path does not exist: ${newPath}`);
return false;
}
const files = fs_1.default.readdirSync(oldPath).filter((file) => file.endsWith('.json'));
if (files.length === 0) {
this.logger.warn('No payload files found in the old folder to compare.');
console.log();
this.logger.info(chalk_1.default.bgYellow.black('WARNING - NO FILES TO COMPARE'));
return false;
}
let isDifferent = false;
let matchedCount = 0;
let diffCount = 0;
const results = [];
const validator = new SchemaValidator_1.SchemaValidator();
files.forEach((file) => {
const oldFilePath = path_1.default.join(oldPath, file);
const newFilePath = path_1.default.join(newPath, file);
if (!fs_1.default.existsSync(newFilePath)) {
isDifferent = true;
diffCount++;
results.push({
fileName: file,
matched: false,
differences: ['X File missing in new version'],
oldContent: this.loadAndParseJson(oldFilePath),
});
return;
}
const oldData = this.loadAndParseJson(oldFilePath);
const newData = this.loadAndParseJson(newFilePath);
const oldIsEmpty = oldData === null;
const newIsEmpty = newData === null;
if (this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) {
matchedCount++;
const msg = `! One or both payloads in ${file} are empty or missing. Ignored due to tolerateEmptyResponses=true.`;
results.push({
fileName: file,
matched: true,
differences: [msg],
oldContent: oldData,
newContent: newData,
});
return;
}
if (!this.options.tolerateEmptyResponses && (oldIsEmpty || newIsEmpty)) {
isDifferent = true;
diffCount++;
const diffs = [
...(oldIsEmpty ? ['X Old data is not a valid object. Got empty or missing.'] : []),
...(newIsEmpty ? ['X New data is not a valid object. Got empty or missing.'] : []),
];
results.push({
fileName: file,
matched: false,
differences: diffs,
oldContent: oldData,
newContent: newData,
});
return;
}
const structuralDiffs = this.comparator.compare(oldData, newData);
const ignoredDiffs = structuralDiffs.filter((d) => d.startsWith('IGNORED::'));
const realDiffs = structuralDiffs.filter((d) => !d.startsWith('IGNORED::'));
const schema = generate_schema_1.default.json('Response', oldData);
if (this.options.strictSchema) {
SchemaStrictifier_1.SchemaStrictifier.enforceNoAdditionalProperties(schema);
}
schema.$schema = 'http://json-schema.org/draft-07/schema#';
const isValid = validator.validate(schema, newData);
const schemaErrors = isValid ? [] : validator.getErrors();
const actualDiffs = [...realDiffs, ...schemaErrors];
const matched = actualDiffs.length === 0;
if (matched) {
matchedCount++;
}
else {
isDifferent = true;
diffCount++;
}
results.push({
fileName: file,
matched,
differences: [...actualDiffs, ...ignoredDiffs],
oldContent: oldData,
newContent: newData,
});
});
results.forEach((result) => {
if (result.matched) {
this.logger.info(chalk_1.default.green(`✓ ${result.fileName} matches`));
result.differences?.forEach((diff) => {
if (diff.startsWith('IGNORED::')) {
this.logger.warn(diff.replace(/^IGNORED::\s*/, ''));
}
});
}
else {
this.logger.error(`X Differences found in ${result.fileName}`);
result.differences?.forEach((diff) => this.logger.error(diff));
}
});
this.configLoader = new ConfigLoader_1.ConfigLoader(); // create ConfigLoader instance
this.config = this.configLoader.loadConfig();
const maxKeyLength = Math.max(...Object.keys(this.config).map(key => key.length));
console.log();
if (!isDifferent) {
this.logger.info(chalk_1.default.bgGreen.bold('SUCCESS - ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG'));
}
else {
this.logger.info(chalk_1.default.bgRed('FAILURE - NOT ALL PAYLOAD FILES MATCH WITH SPECIFIED CONFIG'));
}
Object.entries(this.config).forEach(([key, value]) => {
const paddedKey = key.padEnd(maxKeyLength, ' ');
this.logger.info(`${chalk_1.default.white(paddedKey)} : ${chalk_1.default.white.bold(String(value))}`);
});
const endTime = Date.now();
const elapsedSeconds = ((endTime - startTime) / 1000).toFixed(2);
const summaryParts = [
`${matchedCount} matched`,
diffCount > 0 ? `${diffCount} differed` : null,
`${files.length} total files`,
`in ${elapsedSeconds}s`,
].filter(Boolean);
this.logger.info(chalk_1.default.white('\n ' + summaryParts.join(' | ') + '\n'));
const reporter = new HtmlReporter_1.HtmlReporter();
reporter.generateReport(oldFolder, newFolder, results);
return isDifferent;
}
/**
* Loads and parses a JSON file safely.
* Returns null if the file is empty, null, or invalid JSON.
* @param {string} filePath - Path to the JSON file.
* @returns {any | null} Parsed JSON object or null if empty/invalid.
*/
loadAndParseJson(filePath) {
try {
const raw = fs_1.default.readFileSync(filePath, 'utf-8').trim();
if (!raw || raw === '""' || raw === 'null') {
return null;
}
return JSON.parse(raw);
}
catch (err) {
const error = err;
this.logger.warn(`! Failed to parse JSON at ${filePath}: ${error.message}`);
return null;
}
}
/**
* Logs a formatted header block with a title, a separator line, and a message.
* @param {string} title - Title text for the header.
* @param {string} message - Message text under the title.
*/
logHeader(title, message) {
const separator = '-'.repeat(72);
this.logger.info('');
this.logger.info(chalk_1.default.cyan(title));
this.logger.info(separator);
this.logger.info(message);
this.logger.info('');
}
}
exports.PayloadComparer = PayloadComparer;