@apistudio/apim-cli
Version:
CLI for API Management Products
360 lines (359 loc) • 17.2 kB
JavaScript
/**
* Copyright IBM Corp. 2024, 2025
*/
import { LogWrapper } from '../../service/log-wrapper.js';
import { RestHandler } from '../protocol/rest-handler.js';
import { AssertionEngine } from '../assertion/assertion.engine.js';
import { VCM } from '../variable-context-manager/context-manager.js';
import { compileExpression } from 'filtrex';
import { convertToExecutableFormat } from '../../helpers/condition-converter.js';
import { TestExecutionReport } from '../reporting/test-execution-report.js';
import { sanitizeAxiosResponse } from '../../helpers/helper.js';
export class TestRunner {
constructor(test) {
this.test = test;
}
async run() {
const { metadata: { name, type }, spec: { request: requests }, vcmId, } = this.test;
LogWrapper.logInfo('0215', `Starting Test run: ${name}`);
const assertionSummary = [];
const executions = [];
const startedAt = Date.now();
let assertionResults = [];
let isStopOnFailTriggered = false;
for (const request of requests) {
if (request.skipped) {
continue;
}
let requestCondition = true;
// Check whether request condition is met to execute
if (request.if !== undefined && typeof request.if !== 'boolean') {
try {
// Convert human-readable format to executable format
const executableCondition = convertToExecutableFormat(request.if);
// Use the same approach as in assertion.engine.ts
const resolvedExpression = this.resolveConditionExpression(executableCondition, vcmId);
const exp = compileExpression(resolvedExpression);
requestCondition = exp(resolvedExpression);
}
catch (error) {
console.error(error);
requestCondition = false;
}
}
if (requestCondition) {
const step = { ...request, endpoint: this.constructUrl(request) };
const executionStartedAt = Date.now();
let executionCompletedAt;
try {
await new RestHandler().execute(step, vcmId);
executionCompletedAt = Date.now();
}
catch {
executionCompletedAt = Date.now();
}
// construction execution result
const constructedRequest = VCM.resolve(vcmId, '${request}');
const response = VCM.resolve(vcmId, '${response}');
const requestHeaders = VCM.resolve(vcmId, '${requestHeaders}');
const headers = response?.headers || response?.response?.headers;
response.headers = Object.entries(headers).map(([key, value]) => ({
key,
value,
}));
if (type === 'api-call') {
return sanitizeAxiosResponse(response);
}
const managedRequestHeaders = Object.entries(requestHeaders).map(([key, value]) => ({
key,
value,
}));
executions.push({
id: '', // TODO
itemName: `${request.method} ${request.resource}`,
response: response,
request: {
...step,
endpoint: constructedRequest?.url ?? step.endpoint,
headers: managedRequestHeaders,
},
startedAt: executionStartedAt,
completedAt: executionCompletedAt,
assertions: [],
});
// Pass assertions[] to assert engine
assertionResults = [];
isStopOnFailTriggered = false;
if (request.assertions) {
if (Array.isArray(request.assertions)) {
// Format 1: array of objects with $ref
for (const assertion of request.assertions) {
if (assertion) {
const [result, stopOnFailTriggered] = await new AssertionEngine().assert(assertion, vcmId);
if (result && Array.isArray(result)) {
assertionResults.push(...result);
}
// If stopOnFail was triggered, mark remaining requests as cancelled
if (stopOnFailTriggered) {
isStopOnFailTriggered = true;
// Ensure current request's assertions are included in the results
executions[executions.length - 1].assertions = [
...assertionResults,
];
assertionSummary.push({
request: request.resource,
assertions: [...assertionResults],
});
// Mark all remaining requests as cancelled
for (let i = requests.indexOf(request) + 1; i < requests.length; i++) {
const cancelledExecution = this.createCancelledExecution(requests[i]);
executions.push(cancelledExecution);
assertionSummary.push({
request: requests[i].resource,
assertions: cancelledExecution.assertions,
});
// Mark as skipped to avoid processing in the main loop
requests[i].skipped = true;
}
}
}
}
}
else {
// Format 2: single assertion with direct $ref property
const [result, stopOnFailTriggered] = await new AssertionEngine().assert(request.assertions, vcmId);
if (result && Array.isArray(result)) {
assertionResults.push(...result);
}
// If stopOnFail was triggered, mark remaining requests as cancelled
if (stopOnFailTriggered) {
isStopOnFailTriggered = true;
// Ensure current request's assertions are included in the results
executions[executions.length - 1].assertions = [
...assertionResults,
];
assertionSummary.push({
request: request.resource,
assertions: [...assertionResults],
});
// Mark all remaining requests as cancelled
for (let i = requests.indexOf(request) + 1; i < requests.length; i++) {
const cancelledExecution = this.createCancelledExecution(requests[i]);
executions.push(cancelledExecution);
assertionSummary.push({
request: requests[i].resource,
assertions: cancelledExecution.assertions,
});
// Mark as skipped to avoid processing in the main loop
requests[i].skipped = true;
}
}
}
}
// Create a deep copy of the assertion results to avoid reference issues
// Only add to results if stopOnFail wasn't triggered (otherwise already added)
if (!isStopOnFailTriggered) {
executions[executions.length - 1].assertions = [...assertionResults];
// TODO check the below array is really needed.
assertionSummary.push({
request: request.resource,
assertions: [...assertionResults],
});
}
}
else {
// Mark current request's remaining assertions as cancelled if any
if (request.assertions) {
const cancelledExecution = this.createCancelledExecution(request);
executions.push(cancelledExecution);
assertionSummary.push({
request: request.resource,
assertions: cancelledExecution.assertions,
});
}
}
}
const completedAt = Date.now();
let envMetadata;
const env = this.test.spec.environment;
if (env && !Array.isArray(env) && 'variables' in env) {
envMetadata = env.variables?.[0]?.metadata;
}
const report = new TestExecutionReport().collectReport(vcmId, this.test.metadata.name, assertionSummary, executions, startedAt, completedAt, envMetadata);
// Clean up memory after every test.
VCM.deleteContext(vcmId);
LogWrapper.logInfo('0215', `Completed Test run: ${name}`);
return report;
}
constructUrl(request) {
const { endpoint, resource, parameters } = request;
const { spec: { api: { $endpoint }, }, } = this.test;
// if any endpoint is passed within request that will get precedence.
const url = (endpoint ?? $endpoint);
// Replace path parameters in the resource path
let processedResource = resource;
// Check if parameters exist and process path parameters
if (parameters && Array.isArray(parameters)) {
for (const param of parameters) {
if (param.key && param.value !== undefined) {
// Replace {paramName} with actual value
const paramPattern = new RegExp(`\\{${param.key}\\}`, 'g');
processedResource = processedResource.replace(paramPattern, param.value.toString());
}
}
}
return `${url}${processedResource}`;
}
/**
* Creates cancelled assertion results for assertions that weren't executed
* @param assertionsParam - The assertions that need to be marked as cancelled
* @returns Array of cancelled assertion results
*/
createCancelledAssertions(assertionsParam) {
if (!assertionsParam) {
return [];
}
// Normalize input to handle nested assertions property
const normalizedAssertions = 'assertions' in assertionsParam &&
Array.isArray(assertionsParam.assertions)
? assertionsParam.assertions
: assertionsParam;
// Convert to array if it's a single assertion
const assertionsArray = Array.isArray(normalizedAssertions)
? normalizedAssertions
: [normalizedAssertions];
// Map each assertion to a cancelled RunExecutionAssertion
return assertionsArray
.filter((assertion) => assertion !== null && assertion !== undefined)
.flatMap((assertion) => this.createCancelledAssertion(assertion));
}
/**
* Creates a single cancelled assertion result
* @param assertion - The assertion to convert to a cancelled result
* @returns A RunExecutionAssertion with cancelled status
*/
createCancelledAssertion(assertion) {
// Check if assertion.spec is an array - create cancelled assertion object for each individual item
if (assertion.spec && Array.isArray(assertion.spec)) {
return assertion.spec.map((spec) => ({
assertion: spec.name,
skipped: true,
action: spec.action ?? '',
key: spec.key ?? '',
expectedValue: spec.value,
actualValue: null,
error: {
name: 'CancelledError',
message: 'Test execution stopped due to previous error and stopOnFail flag',
stack: '',
test: assertion.metadata?.name,
},
}));
}
const assertionName = assertion.spec?.name;
const testName = assertion.metadata?.name;
return {
assertion: assertionName,
skipped: true,
action: assertion.spec?.action ?? '',
// Handle inconsistent property access between array and single assertion cases
key: assertion.spec?.key ?? assertion.metadata?.key ?? '',
expectedValue: assertion.spec?.value,
actualValue: null,
error: {
name: 'CancelledError',
message: 'Test execution stopped due to previous error and stopOnFail flag',
stack: '',
test: testName,
},
};
}
/**
* Creates a cancelled execution result for a request that wasn't executed
* @param request - The request that wasn't executed
* @returns TestExecutionResult with cancelled status
*/
createCancelledExecution(request) {
const cancelledAssertions = this.createCancelledAssertions(request.assertions);
return {
id: '',
itemName: `${request.method} ${request.resource}`,
response: {
headers: [],
status: 0,
statusText: 'Cancelled',
},
request: {
...request,
endpoint: this.constructUrl(request),
headers: [],
},
startedAt: Date.now(),
completedAt: Date.now(),
assertions: cancelledAssertions,
};
}
/**
* Resolves a condition expression, handling variable references properly
* Similar to the approach used in assertion.engine.ts
*/
resolveConditionExpression(expression, contextId) {
// If the expression is already a complex expression with operators, resolve it as is
if (expression.includes('==') ||
expression.includes('!=') ||
expression.includes('>') ||
expression.includes('<') ||
expression.includes('&&') ||
expression.includes('||')) {
// Replace all ${...} patterns with their resolved values
return expression.replace(/\$\{([^{}]*)\}/g, (match, expr) => {
try {
// Use the same approach as in assertion.engine.ts
const value = this.resolveExpressionValue(expr, contextId);
// Convert the value to a string representation suitable for filtrex
if (value === undefined || value === null) {
return '""'; // Empty string for undefined/null values
}
else if (typeof value === 'string') {
return `"${value}"`; // Wrap strings in quotes
}
else if (typeof value === 'object') {
return '""'; // Empty string for objects that can't be used in expressions
}
else {
return String(value); // Convert numbers, booleans, etc. to string
}
}
catch (e) {
console.error(e);
return '""'; // Return empty string on error
}
});
}
// If it's a simple variable reference, resolve it directly
return VCM.resolve(contextId, expression);
}
/**
* Resolves an expression value, handling path resolution properly
*/
resolveExpressionValue(expr, contextId) {
const parts = expr.split('.');
const baseKey = parts[0];
// Get the base value from the context
const context = VCM.getContext(contextId);
const global = VCM.getGlobalContext();
let resolved = context?.getValue(baseKey) ?? global?.getValue(baseKey);
if (resolved === undefined) {
return undefined;
}
// Navigate through the path parts
for (const part of parts.slice(1)) {
const key = /^\d+$/.test(part) ? Number(part) : part;
if (resolved === undefined || resolved === null || !(key in resolved)) {
return undefined;
}
resolved = resolved[key];
}
return resolved;
}
}