UNPKG

specmatic

Version:
335 lines (299 loc) 12.4 kB
import axios from 'axios' import path from 'path' import { ChildProcess } from 'child_process' import { XMLParser } from 'fast-xml-parser' import fs from 'fs' import logger from '../common/logger' import { callCore } from '../common/runner' import listExpressEndpoints from '../lib/express-list-endpoints'; import http from 'http' import { AddressInfo } from 'net' import { gracefulShutdown } from './shutdownUtils' const freePortMessage = 'Free port found' const stubServingMessage = 'serving endpoints from specs' export class Stub { host: string port: number url: string process: ChildProcess constructor(host: string, port: number, url: string, process: ChildProcess) { this.host = host this.port = port this.url = url this.process = process } } const startStub = (host?: string, port?: number, args?: (string | number)[]): Promise<Stub> => { var cmd = [`stub`] if (host) cmd .push(`--host=${host}`) if (port) cmd .push(`--port=${port}`) if (args) cmd .push(...args.map(arg => String(arg))) logger.info('Stub: Starting server') logger.debug(`Stub: Executing "${cmd}"`) let parsedPort: string | null = port == null || port === 0 ? null : String(port); return new Promise((resolve, reject) => { const javaProcess = callCore( cmd, (err: any) => { if (err) { logger.error(`Stub: Exited with error ${err}`) } }, (message, error) => { if (!error) { if (message.indexOf(freePortMessage) > -1) { logger.info(`Stub: ${message}`) parsedPort = parsedPort ?? message.split(`${freePortMessage}:`)[1].trim() } else if (message.indexOf(stubServingMessage) > -1) { logger.info(`Stub: ${message}`) const stubInfo = message.split(stubServingMessage) if (stubInfo.length < 2) reject('Cannot determine url from stub output') else try { const stub = parseStubOutput(stubInfo, parsedPort, javaProcess); resolve(stub); } catch (e) { reject(extractErrorMessage(e)); } } else if (message.indexOf('Address already in use') > -1) { logger.error(`Stub: ${message}`) reject('Address already in use') } else { logger.debug(`Stub: ${message}`) } } else { logger.error(`Stub: ${message}`) } } ) }) } function extractErrorMessage(error: any): string { if (typeof error === 'string') { return error; } if (error instanceof Error) { return error.message; } return String(error); } function parseStubOutput( stubInfo: string[], parsedPort: string | null, javaProcess: ChildProcess ): Stub { const url = stubInfo[0].trim(); const urlInfo = /\b(.*?):\/\/(.*?):?([0-9]+)?(\/.*)?$/.exec(url); if (urlInfo === null || !urlInfo[1] || !urlInfo[2]) { throw new Error('Cannot determine host and port from stub output'); } const protocol = urlInfo[1]; const host = urlInfo[2]; const regexPort = urlInfo[3]; const path = urlInfo[4] || ""; const defaultPort = protocol === 'http' ? '80' : protocol === 'https' ? '443' : undefined; const finalPort = parsedPort ?? regexPort ?? defaultPort; if (!finalPort) { throw new Error('Cannot determine port from stub output'); } const portNumber = Number.parseInt(finalPort, 10); if (Number.isNaN(portNumber) || portNumber <= 0 || portNumber > 65535) { throw new Error(`Invalid port: ${finalPort}`); } const finalUrl = `${protocol}://${host}:${finalPort}${path}`; return new Stub(host, Number.parseInt(finalPort), finalUrl, javaProcess); } const stopStub = async (stub: Stub) => { logger.debug(`Stub: Stopping server at ${stub.url}`) const javaProcess = stub.process javaProcess.stdout?.removeAllListeners() javaProcess.stderr?.removeAllListeners() javaProcess.removeAllListeners('close') logger.debug('Trying to stop stub process gracefully ...') await gracefulShutdown(javaProcess) logger.debug('Completed graceful termination of the stub process') logger.info(`Stub: Stopped server at ${stub.url}`) } const testWithApiCoverage = async ( expressApp: any, host?: string, port?: number, contractPath?: string, args?: (string | number)[] ): Promise<{ [k: string]: number } | undefined> => { return new Promise(async (resolve, _reject) => { let apiCoverageServer = await startApiCoverageServer(expressApp) const results = await test(host, port, contractPath, args) apiCoverageServer.close(() => { resolve(results) }) }) } const test = (host?: string, port?: number, contractPath?: string, args?: (string | number)[]): Promise<{ [k: string]: number } | undefined> => { const specsPath = path.resolve(contractPath + '') const cmd = [`test`] if (contractPath) cmd.push(specsPath) cmd.push('--junitReportDir=dist/test-report') if (host) cmd.push(`--host=${host}`) if (port) cmd.push(`--port=${port}`) if (args) cmd.push(...args.map(arg => String(arg))) logger.info('Test: Running') logger.debug(`Test: Executing "${cmd}"`) const reportDir = path.resolve('dist/test-report') fs.rmSync(reportDir, { recursive: true, force: true }) return new Promise((resolve, _reject) => { callCore( cmd, (err: any) => { if (err) logger.error(`Test: Failed with error ${err}`) var testCases = parseJunitXML() const total = testCases.length const failure = testCases.filter((testcase: { [id: string]: any }) => testcase['failure'] || testcase['skipped']).length const success = total - failure var result = { total, success, failure } resolve(result) }, (message, error) => { if (message.indexOf('API COVERAGE SUMMARY') > -1) { console.log(message) //Log always for all log levels } else { logger[error ? 'error' : 'debug'](`Test: ${message}`) } } ) }) } const showTestResults = (testFn: (name: string, cb: () => void) => void) => { var testCases = parseJunitXML() testCases.map(function (testcase: { [id: string]: any }) { var name = 'No Name' if (testcase['system-out']) { const nameTempArr = testcase['system-out'] .trim() .replace(/\n/g, '') .split(/display-name:.*Scenario: /) if (nameTempArr.length > 1) name = nameTempArr[1].trim() } testFn(name, () => { if (testcase.failure || testcase.skipped) throw new Error('Did not pass') }) }) } const setExpectations = (stubPath: string, stubServerBaseUrl?: string): Promise<void> => { const stubResponse = require(path.resolve(stubPath)) return setExpectationJson(stubResponse, stubServerBaseUrl) } const setExpectationJson = (stubResponse: any, stubServerBaseUrl?: string): Promise<void> => { stubServerBaseUrl = stubServerBaseUrl || 'http://localhost:9000' logger.info(`Set Expectations: Stub url is ${stubServerBaseUrl}`) return new Promise((resolve, reject) => { axios .post(`${stubServerBaseUrl}/_specmatic/expectations`, stubResponse) .then(response => { logger.debug(`Set Expectations: ${response.data}`) logger.info('Set Expectations: Finished') resolve() }) .catch(err => { var setExpectationsErrorMessage = `Set Expectations: Failed with error ${err}` // Check if the response data is in text format if (err && err.response?.headers['content-type'] === 'text/plain') { try { // Use a check to handle text content const errorText = typeof err.response.data === 'string' ? err.response.data : "Error text not available"; setExpectationsErrorMessage += ` - ${errorText}`; } catch (e) { logger.error("Failed to retrieve text error message."); } } logger.error(setExpectationsErrorMessage) reject(setExpectationsErrorMessage) }) }) } const printJarVersion = () => { const cmd = [`--version`] logger.info('Print Jar Version: Running') logger.debug(`Print Jar Version: Executing "${cmd}"`) callCore( cmd, (err: any) => { if (err) logger.error(`Print Jar Version: Failed with error ${err}`) }, (message, error) => { if (error) logger.error(`Print Jar Version: ${message}`) else console.log(`${message}`) } ) } const startApiCoverageServer = (expressApp: any): Promise<http.Server> => { logger.debug(`Registering API endpoint for coverage`) let app = http.createServer((_req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }) const endPoints = JSON.stringify(extractEndPoints(expressApp)) logger.debug(`Endpoints: ${endPoints}`) res.end(endPoints) }) return new Promise<http.Server>((resolve, reject) => { app.on('error', err => { logger.error('Error while starting end points server for api coverage', err) reject('Error while starting end points server for api coverage') }) app.listen({ host: '127.0.0.1', port: 0 }, () => { const address = app.address() as AddressInfo | null process.env['endpointsAPI'] = `http://${address?.address}:${address?.port}` logger.info(`Endpoints API registered at ${process.env['endpointsAPI']}`) resolve(app) }) }) } const parseJunitXML = () => { const reportPath = path.resolve('dist/test-report/TEST-junit-jupiter.xml') var data = fs.readFileSync(reportPath) const parser = new XMLParser() var resultXml = parser.parse(data) resultXml.testsuite.testcase = Array.isArray(resultXml.testsuite.testcase) ? resultXml.testsuite.testcase : [resultXml.testsuite.testcase] return resultXml.testsuite.testcase } const listEndPoints = (expressApp: any): { [key: string]: string[] } => { const details = listExpressEndpoints(expressApp) let endPoints: { [key: string]: string[] } = {} details.map(apiDetail => { endPoints[apiDetail.path] = apiDetail.methods }) delete endPoints['*'] return endPoints } function extractEndPoints(expressApp: any) { let endPoints = listEndPoints(expressApp) let springActuatorPayload = { contexts: { application: { mappings: { dispatcherServlets: { dispatcherServlet: [], }, }, }, }, } Object.keys(endPoints) .sort() .map(path => { springActuatorPayload.contexts.application.mappings.dispatcherServlets.dispatcherServlet.push({ details: { requestMappingConditions: { methods: endPoints[path].sort(), patterns: [convertEndpointToSpringSyntax(path)].map(removeRegexFromPath) }, }, } as never) }) return springActuatorPayload } const convertEndpointToSpringSyntax = (path: string) => { return path.replace(/:([^/]+)/g, '{$1}').replace(/\\/g, '') } const removeRegexFromPath = (path: string) => { return path.replace(/\([^()]*\)/g, '') } export { startStub, stopStub, test, testWithApiCoverage, setExpectations, setExpectationJson, printJarVersion, showTestResults } export { startApiCoverageServer }