UNPKG

@apistudio/apim-cli

Version:

CLI for API Management Products

360 lines (359 loc) 17.2 kB
/** * 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; } }