@apistudio/apim-cli
Version:
CLI for API Management Products
237 lines (218 loc) • 7.5 kB
text/typescript
/**
* Copyright IBM Corp. 2024, 2025
*/
import { compileExpression } from 'filtrex';
import { performAssertion } from '../../handlers/assertion.handler.js';
import { RunExecutionAssertion } from '../../models/interface.js';
import { Assertions } from '../../schemas/test.schema.js';
import { VCM } from '../variable-context-manager/context-manager.js';
import { convertToExecutableFormat } from '../../helpers/condition-converter.js';
// Interface for wildcard match results
interface WildcardResult {
isWildcardResult: true;
matches: any[];
originalPath: string;
}
export class AssertionEngine {
async assert(
assertions: Assertions,
contextId: string,
): Promise<[RunExecutionAssertion[], boolean]> {
const results: RunExecutionAssertion[] = [];
let isStopOnFailTriggered = false;
for (const assertion of assertions.assertions!) {
for (const spec of assertion.spec) {
let assertionExecutionCheck = true;
const {
name,
action,
key,
value,
if: conditionCheck,
stopOnFail,
} = spec;
if (
conditionCheck !== undefined &&
typeof conditionCheck !== 'boolean'
) {
try {
// Convert human-readable format to executable format
const executableCondition =
convertToExecutableFormat(conditionCheck);
const resolvedExpression = VCM.resolve(
contextId,
executableCondition,
);
const exp = compileExpression(resolvedExpression);
assertionExecutionCheck = exp(resolvedExpression);
} catch (error) {
console.error(error);
assertionExecutionCheck = false;
}
}
if (assertionExecutionCheck) {
let actualValue;
let expectedValue;
try {
expectedValue = this.resolveValue(value, contextId);
actualValue = this.resolveKey(key, contextId);
// Handle wildcard results differently
if (this.isWildcardResult(actualValue)) {
this.performWildcardAssertion(
action,
actualValue,
expectedValue,
name,
);
} else {
performAssertion(action, actualValue, expectedValue);
}
results.push({
metadata: assertion.metadata,
assertion: name,
skipped: false,
actualValue: this.isWildcardResult(actualValue)
? actualValue.matches
: actualValue,
expectedValue,
action,
key,
});
} catch (error: any) {
results.push({
metadata: assertion.metadata,
assertion: name,
skipped: false,
error: {
name: error?.name || 'AssertionError',
test: name,
message: error?.message || '',
stack: error?.stack || '',
},
actualValue: this.isWildcardResult(actualValue)
? actualValue?.matches
: actualValue,
expectedValue,
action,
key,
});
if (stopOnFail) {
// Mark remaining assertions as skipped
const remainingAssertions = assertions.assertions!.flatMap((a) =>
a.spec
.filter((s) => !results.some((r) => r.assertion === s.name))
.map((s) => ({
metadata: a.metadata,
assertion: s.name,
skipped: true,
error: undefined,
action: s.action,
key: s.key,
})),
);
results.push(...remainingAssertions);
// Return a flag to indicate the test was terminated due to stop on fail
// This flag needs to be checked in TestRunner to cancel all remaining requests
isStopOnFailTriggered = true;
break;
}
}
} else {
// if assertionExecutionCheck is not passed, it will skip the test.
results.push({
metadata: assertion.metadata,
assertion: name,
skipped: true,
error: undefined,
action,
key,
});
}
}
}
return [results, isStopOnFailTriggered];
}
// For backward compatibility we check whether key have ${ to resolve it}
private resolveKey(key: string, contextId: string): any {
if (key.includes('*')) {
return this.resolveWildcardKey(key, contextId);
}
return this.resolveValue(this.wrapKey(key), contextId);
}
private wrapKey(key: string): string {
return key.includes('${') ? key : `\${${key}}`;
}
private resolveValue(key: any, contextId: string): any {
return VCM.resolve(contextId, key);
}
/**
* Helper method to check if a value is a wildcard result
*/
private isWildcardResult(value: any): value is WildcardResult {
return (
value && typeof value === 'object' && value.isWildcardResult === true
);
}
/**
* Performs assertions on each item in a wildcard result
* If any assertion fails, the entire assertion fails
*/
private performWildcardAssertion(
action: string,
wildcardResult: WildcardResult,
expected: any,
assertionName: string,
): void {
if (wildcardResult.matches.length === 0) {
throw new Error(
`No matches found for wildcard path: ${wildcardResult.originalPath}`,
);
}
// Check each match against the expected value
for (const match of wildcardResult.matches) {
try {
performAssertion(action, match, expected);
} catch (error: any) {
throw new Error(
`Assertion '${assertionName}' failed for path '${wildcardResult.originalPath}': ${error.message}`,
);
}
}
}
private resolveWildcardKey(key: string, contextId: string): WildcardResult {
const parts = key.split('.');
const rootKey = parts[0];
const root = this.resolveValue(this.wrapKey(rootKey), contextId);
if (!root)
return { isWildcardResult: true, matches: [], originalPath: key };
const matches = this.collectWildcardMatches(root, parts.slice(1));
return { isWildcardResult: true, matches, originalPath: key };
}
private collectWildcardMatches(node: any, pathParts: string[]): any[] {
if (pathParts.length === 0) {
return [node];
}
const [head, ...tail] = pathParts;
const results: any[] = [];
if (head === '*') {
if (Array.isArray(node)) {
// Iterate array items
for (const item of node) {
results.push(...this.collectWildcardMatches(item, tail));
}
} else if (
node &&
Object.prototype.toString.call(node) === '[object Object]'
) {
// Iterate plain object values only (not arrays)
for (const key of Object.keys(node)) {
results.push(...this.collectWildcardMatches(node[key], tail));
}
}
// Skip if node is not iterable
} else if (node && typeof node === 'object' && head in node) {
results.push(...this.collectWildcardMatches(node[head], tail));
}
return results;
}
}