jam-cloud/jam-ui/test/utils/sequence-comparator.ts

294 lines
9.0 KiB
TypeScript

import { APICall } from './api-interceptor';
export interface ComparisonResult {
matches: boolean;
totalCalls: number;
matchedCalls: number;
missingCalls: EndpointMismatch[];
extraCalls: EndpointMismatch[];
outOfOrderCalls: OrderMismatch[];
timingVariances: TimingVariance[];
report: string;
}
export interface EndpointMismatch {
endpoint: string;
expectedCount: number;
actualCount: number;
}
export interface OrderMismatch {
endpoint: string;
expectedPosition: number;
actualPosition: number;
deviation: number;
}
export interface TimingVariance {
endpoint: string;
expectedTiming: number;
actualTiming: number;
variance: number;
}
/**
* Compare two API call sequences
*/
export function compareAPISequences(
actual: APICall[],
expected: APICall[]
): ComparisonResult {
const missing: EndpointMismatch[] = [];
const extra: EndpointMismatch[] = [];
const outOfOrder: OrderMismatch[] = [];
const timingVariances: TimingVariance[] = [];
// Build endpoint maps
const actualEndpoints = buildEndpointMap(actual);
const expectedEndpoints = buildEndpointMap(expected);
// Find missing calls (in expected but not in actual)
for (const [endpoint, expectedCalls] of expectedEndpoints.entries()) {
const actualCalls = actualEndpoints.get(endpoint) || [];
if (actualCalls.length === 0) {
missing.push({
endpoint,
expectedCount: expectedCalls.length,
actualCount: 0,
});
} else if (actualCalls.length < expectedCalls.length) {
missing.push({
endpoint,
expectedCount: expectedCalls.length,
actualCount: actualCalls.length,
});
}
}
// Find extra calls (in actual but not in expected)
for (const [endpoint, actualCalls] of actualEndpoints.entries()) {
const expectedCalls = expectedEndpoints.get(endpoint) || [];
if (expectedCalls.length === 0) {
extra.push({
endpoint,
expectedCount: 0,
actualCount: actualCalls.length,
});
} else if (actualCalls.length > expectedCalls.length) {
extra.push({
endpoint,
expectedCount: expectedCalls.length,
actualCount: actualCalls.length,
});
}
}
// Check order
const orderMismatches = checkOrder(actual, expected);
outOfOrder.push(...orderMismatches);
// Check timing
const timings = checkTimingVariance(actual, expected);
timingVariances.push(...timings);
// Calculate match percentage
const totalExpected = expected.length;
const matched = totalExpected - missing.reduce((sum, m) => sum + (m.expectedCount - m.actualCount), 0);
const matchPercentage = totalExpected > 0 ? (matched / totalExpected) * 100 : 0;
// Generate report
const report = generateReport({
matches: matchPercentage >= 95,
totalCalls: actual.length,
matchedCalls: matched,
missingCalls: missing,
extraCalls: extra,
outOfOrderCalls: outOfOrder,
timingVariances: timingVariances,
report: '',
});
return {
matches: matchPercentage >= 95,
totalCalls: actual.length,
matchedCalls: matched,
missingCalls: missing,
extraCalls: extra,
outOfOrderCalls: outOfOrder,
timingVariances: timingVariances,
report,
};
}
/**
* Build a map of endpoint -> calls
*/
function buildEndpointMap(calls: APICall[]): Map<string, APICall[]> {
const map = new Map<string, APICall[]>();
for (const call of calls) {
const endpoint = `${call.method} ${call.pathname}`;
if (!map.has(endpoint)) {
map.set(endpoint, []);
}
map.get(endpoint)!.push(call);
}
return map;
}
/**
* Check if calls are in the same order
*/
function checkOrder(actual: APICall[], expected: APICall[]): OrderMismatch[] {
const mismatches: OrderMismatch[] = [];
// Create a sequence of endpoints for both
const actualSequence = actual.map(c => `${c.method} ${c.pathname}`);
const expectedSequence = expected.map(c => `${c.method} ${c.pathname}`);
// For each expected endpoint, find its position in actual
for (let i = 0; i < expectedSequence.length; i++) {
const endpoint = expectedSequence[i];
const actualIndex = actualSequence.indexOf(endpoint);
if (actualIndex !== -1) {
const deviation = Math.abs(actualIndex - i);
// Only report if deviation is significant (more than 3 positions)
if (deviation > 3) {
mismatches.push({
endpoint,
expectedPosition: i,
actualPosition: actualIndex,
deviation,
});
}
}
}
return mismatches;
}
/**
* Check timing variance between calls
*/
function checkTimingVariance(actual: APICall[], expected: APICall[]): TimingVariance[] {
const variances: TimingVariance[] = [];
if (actual.length === 0 || expected.length === 0) {
return variances;
}
// Calculate relative timings (time from first call)
const actualStartTime = actual[0].timestamp;
const expectedStartTime = expected[0].timestamp;
const actualTimings = new Map<string, number[]>();
const expectedTimings = new Map<string, number[]>();
for (const call of actual) {
const endpoint = `${call.method} ${call.pathname}`;
const relativeTime = call.timestamp - actualStartTime;
if (!actualTimings.has(endpoint)) {
actualTimings.set(endpoint, []);
}
actualTimings.get(endpoint)!.push(relativeTime);
}
for (const call of expected) {
const endpoint = `${call.method} ${call.pathname}`;
const relativeTime = call.timestamp - expectedStartTime;
if (!expectedTimings.has(endpoint)) {
expectedTimings.set(endpoint, []);
}
expectedTimings.get(endpoint)!.push(relativeTime);
}
// Compare average timings
for (const [endpoint, expectedTimes] of expectedTimings.entries()) {
const actualTimes = actualTimings.get(endpoint);
if (actualTimes && actualTimes.length > 0) {
const avgExpected = expectedTimes.reduce((a, b) => a + b, 0) / expectedTimes.length;
const avgActual = actualTimes.reduce((a, b) => a + b, 0) / actualTimes.length;
const variance = Math.abs(avgActual - avgExpected);
// Only report if variance is significant (more than 500ms)
if (variance > 500) {
variances.push({
endpoint,
expectedTiming: avgExpected,
actualTiming: avgActual,
variance,
});
}
}
}
return variances;
}
/**
* Generate a human-readable comparison report
*/
function generateReport(result: ComparisonResult): string {
let report = '# API Sequence Comparison Report\n\n';
report += `## Summary\n\n`;
report += `- **Match Status:** ${result.matches ? '✅ PASS' : '❌ FAIL'}\n`;
report += `- **Total Calls:** ${result.totalCalls}\n`;
report += `- **Matched Calls:** ${result.matchedCalls}\n`;
report += `- **Match Percentage:** ${((result.matchedCalls / result.totalCalls) * 100).toFixed(1)}%\n\n`;
if (result.missingCalls.length > 0) {
report += `## ❌ Missing API Calls\n\n`;
report += `The following expected API calls are missing or called fewer times:\n\n`;
for (const missing of result.missingCalls) {
report += `- **${missing.endpoint}**\n`;
report += ` - Expected: ${missing.expectedCount} call(s)\n`;
report += ` - Actual: ${missing.actualCount} call(s)\n`;
report += ` - Missing: ${missing.expectedCount - missing.actualCount} call(s)\n\n`;
}
}
if (result.extraCalls.length > 0) {
report += `## ⚠️ Extra API Calls\n\n`;
report += `The following API calls were made but not expected:\n\n`;
for (const extra of result.extraCalls) {
report += `- **${extra.endpoint}**\n`;
report += ` - Expected: ${extra.expectedCount} call(s)\n`;
report += ` - Actual: ${extra.actualCount} call(s)\n`;
report += ` - Extra: ${extra.actualCount - extra.expectedCount} call(s)\n\n`;
}
}
if (result.outOfOrderCalls.length > 0) {
report += `## ⚠️ Out of Order Calls\n\n`;
report += `The following API calls occurred in a different order:\n\n`;
for (const order of result.outOfOrderCalls) {
report += `- **${order.endpoint}**\n`;
report += ` - Expected position: ${order.expectedPosition}\n`;
report += ` - Actual position: ${order.actualPosition}\n`;
report += ` - Deviation: ${order.deviation} positions\n\n`;
}
}
if (result.timingVariances.length > 0) {
report += `## ⏱️ Timing Variances\n\n`;
report += `The following API calls have significant timing differences:\n\n`;
for (const timing of result.timingVariances) {
report += `- **${timing.endpoint}**\n`;
report += ` - Expected timing: ${timing.expectedTiming.toFixed(0)}ms\n`;
report += ` - Actual timing: ${timing.actualTiming.toFixed(0)}ms\n`;
report += ` - Variance: ${timing.variance.toFixed(0)}ms\n\n`;
}
}
if (result.matches) {
report += `## ✅ Conclusion\n\n`;
report += `The API sequence matches the expected baseline with acceptable variance.\n`;
} else {
report += `## ❌ Conclusion\n\n`;
report += `The API sequence does NOT match the expected baseline. Please review the mismatches above.\n`;
}
return report;
}