UNPKG

api-coverage-tracker

Version:

A universal library for tracking API coverage against OpenAPI/Swagger specifications from URL or local file. Supports Axis, Fetch, Playwright and manual registry of the responses

1,231 lines (1,073 loc) 42.3 kB
//new import SwaggerParser from '@apidevtools/swagger-parser' import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { parser } from './postman-coverage.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) /** * @typedef {Object} OpenAPISpec * @property {Object} [paths] - API paths * @property {string} [basePath] - Base path for OpenAPI 2.0 * @property {Array<{url: string}>} [servers] - Servers for OpenAPI 3.0+ * @property {Object} info - API info * @property {Object} [components] - OpenAPI components * @property {string} [jsonSchemaDialect] - JSON Schema dialect * @property {Object} [webhooks] - Webhooks */ /** * @typedef {Object} ServiceConfig * @property {string} key - Service key * @property {string} name - Service name * @property {string[]} tags - Service tags * @property {string} repository - Service repository URL * @property {string} swaggerUrl - Swagger/OpenAPI spec URL * @property {string} swaggerFile - Local Swagger/OpenAPI spec file path */ /** * Class for tracking API coverage and generating coverage reports * @class */ export class ApiCoverage { /** * Create a new ApiCoverage instance * @param {Object} config - Configuration object * @param {ServiceConfig[]} config.services - Array of service configurations */ constructor(config) { this.apiSpec = null this.coverageMap = new Map() this.endpoints = new Map() this.originalRequest = null this.basePath = '' this.debug = false this.clientType = null this.clientInstance = null this.queryParamsCoverage = new Map() this.coverageType = 'basic' this.config = config this.BASE_PATH = config['report-path'] this.TEMPLATE_PATH = path.resolve(__dirname, './templates/index.html') this.JSON_REPORT_PATH = `${config['report-path']}/report.json` this.JSON_REPORT_HISTORY_PATH = `${config['report-path']}/history.json` this.REPORT_PATH = `${config['report-path']}/report.html` } /** * Enable or disable debug logging * @param {boolean} enabled - Whether to enable debug logging * @example * apiCoverage.setDebug(true); // Enable debug logging * apiCoverage.setDebug(false); // Disable debug logging */ setDebug(enabled) { this.debug = enabled } /** * Debug log function that only logs when debug is enabled * @param {string} message - Message to log * @private */ log(message) { if (this.debug) { console.log(`[ApiCoverage] ${message}`) } } /** * Load OpenAPI specification from a file or URL * @param {string} source - Path to the Swagger/OpenAPI spec file or URL * @returns {Promise<OpenAPISpec>} - Returns parsed schema if the spec is successfully loaded * @throws {Error} - Throws an error if the spec cannot be loaded or parsed * @example * // Load from URL * await apiCoverage.loadSpec('https://api.example.com/swagger.json'); * // Load from local file * await apiCoverage.loadSpec('./swagger.json'); */ async loadSpec(source) { if (!fs.existsSync(this.JSON_REPORT_HISTORY_PATH)) { // Create empty history file if it doesn't exist await this.#safeWriteFile(this.JSON_REPORT_HISTORY_PATH, JSON.stringify([], null, 2)) this.log(`Created new empty history file at ${this.JSON_REPORT_HISTORY_PATH}`) } this.config.source = source if (this.apiSpec) return this.apiSpec try { const urlRegex = /^https?:\/\// if (urlRegex.test(source)) { this.apiSpec = await SwaggerParser.validate(source) } else { const resolvedPath = path.resolve(source) this.apiSpec = await SwaggerParser.validate(resolvedPath) } this.#parseEndpoints() // Handle both OpenAPI 2.0 (Swagger) and OpenAPI 3.0+ specs // @ts-ignore - We know these properties exist in OpenAPI specs this.basePath = this.apiSpec.basePath || // @ts-ignore - We know these properties exist in OpenAPI specs (this.apiSpec.servers && this.apiSpec.servers[0]?.url ? // @ts-ignore - We know these properties exist in OpenAPI specs this.apiSpec.servers[0].url.replace(/^https?:\/\/[^/]+/, '') : '') this.log(`Loaded API spec with basePath: ${this.basePath}`) this.log(`Parsed ${this.endpoints.size} endpoints`) return this.apiSpec } catch (error) { throw new Error(`Failed to load API spec: ${error.message}`) } } /** * Parse the API specification to extract all endpoints * @description Extracts all endpoints from the OpenAPI spec and stores them in the endpoints Map */ #parseEndpoints() { if (this.endpoints.size > 0) return const { paths } = this.apiSpec for (const path in paths) { const pathItem = paths[path] for (const method in pathItem) { if (['get', 'post', 'put', 'delete', 'patch', 'options', 'head'].includes(method)) { const endpoint = { path, method: method.toUpperCase(), operation: pathItem[method], covered: false, // Create a regex pattern for this path pathRegex: this.#createPathRegex(path) } const key = `${endpoint.path} ${endpoint.method}` this.endpoints.set(key, endpoint) } } } this.log(`Parsed ${this.endpoints.size} endpoints from API spec`) // Log all endpoints in debug mode for (const [key, endpoint] of this.endpoints.entries()) { this.log(`Registered endpoint: ${key} (regex: ${endpoint.pathRegex})`) } } /** * Create a regex pattern for matching an OpenAPI path * @param {string} pathTemplate - OpenAPI path template (e.g., /pet/{petId}) * @returns {RegExp} - Regular expression for matching this path * @example * // Returns regex for matching /pet/123 * _createPathRegex('/pet/{petId}') */ #createPathRegex(pathTemplate) { let pattern = pathTemplate.replace(/[.*+?^${}()[\]\\]/g, match => (match === '{' || match === '}' ? match : '\\' + match)) // Replace {paramName} with a regex that matches single path segment(s) pattern = pattern.replace(/\{[^/{}]+\}/g, '([^/]+)') // '([^/]+(?:/[^/]+)*)' - multimple path segments const segments = pattern.split('/').filter(Boolean) pattern = segments .map((segment, index) => { if (segment.includes('(') && segment.includes(')')) { return segment + '(?=/|$)' } return segment }) .join('/') pattern = `^/${pattern}/?$` return new RegExp(pattern) } /** * Start tracking API requests by patching the provided HTTP client * @param {Object} client - HTTP client instance (Playwright APIRequestContext, Axios instance, etc.) * @param {Object} [options] - Options object * @param {'playwright' | 'axios' | 'fetch'} [options.clientType='playwright'] - Type of client ('playwright', 'axios', 'fetch') * @param {'basic' | 'detailed'} [options.coverage='basic'] - Coverage type ('basic', 'detailed') * @returns {boolean} - Whether tracking was successfully started * @throws {Error} - Throws an error if client type is unsupported * @example * // Start tracking with Playwright * await apiCoverage.startTracking(playwright.request, { clientType: 'playwright' }); * // Start tracking with Axios * await apiCoverage.startTracking(axios, { clientType: 'axios' }); */ startTracking(client, options = { clientType: 'playwright', coverage: 'basic' }) { // Only patch if not already patched if (this.originalRequest) { this.log('Already tracking requests, ignoring startTracking call') return } this.clientType = options.clientType this.clientInstance = client this.coverageType = options.coverage this.log(`Starting tracking with client type: ${options.clientType}`) switch (options.clientType.toLowerCase()) { case 'playwright': this.#patchPlaywright(client) break case 'axios': this.#patchAxios(client) break case 'fetch': this.#patchFetch(client) break default: throw new Error(`Unsupported client type: ${options.clientType}`) } return true } /** * Patch Playwright's APIRequestContext * @param {import('@playwright/test').APIRequestContext} request - Playwright APIRequestContext * @description Patches Playwright's request methods to track API coverage */ #patchPlaywright(request) { this.playwright = true // Store original methods for later restoration this.originalRequest = {} const methodsToTrack = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] methodsToTrack.forEach(method => { if (typeof request[method] === 'function') { this.originalRequest[method] = request[method] // Override the method to track usage request[method] = async (url, options) => { // Call the original method const response = await this.originalRequest[method].call(request, url, options) // Track this endpoint try { // Handle both full URLs and relative paths let pathname let queryParams = {} try { const parsedUrl = new URL(url) pathname = parsedUrl.pathname // Extract query parameters for (const [key, value] of parsedUrl.searchParams.entries()) { queryParams[key] = value } } catch { // If URL parsing fails, assume it's a path const [path, query] = url.split('?') pathname = path // Extract query parameters if present if (query) { const params = new URLSearchParams(query) for (const [key, value] of params.entries()) { queryParams[key] = value } } } this.log(`Tracking request: ${method.toUpperCase()} ${pathname}`) await this.#markEndpointCovered(method.toUpperCase(), pathname, response.status(), queryParams) } catch (error) { console.warn(`Failed to track request: ${method} ${url}`, error) } return response } } }) } /** * Patch Axios instance * @param {import('axios').AxiosInstance} axiosInstance - Axios instance * @description Patches Axios instance methods to track API coverage */ #patchAxios(axiosInstance) { this.axios = true this.log('Patching Axios instance') // Store original methods for later restoration this.originalRequest = { request: axiosInstance.request } // Override the request method to track usage axiosInstance.request = async config => { this.log(`Axios request called with config: ${JSON.stringify(config)}`) // Call the original method const response = await this.originalRequest.request.call(axiosInstance, config) // Track this endpoint try { const method = (config.method || 'get').toUpperCase() const url = config.url || '' // Handle both full URLs and relative paths let pathname let queryParams = {} try { const parsedUrl = new URL(url) pathname = parsedUrl.pathname // Extract query parameters for (const [key, value] of parsedUrl.searchParams.entries()) { queryParams[key] = value } } catch { // If URL parsing fails, assume it's a path const [path, query] = url.split('?') pathname = path // Extract query parameters if present if (query) { const params = new URLSearchParams(query) for (const [key, value] of params.entries()) { queryParams[key] = value } } } this.log(`Tracking Axios request: ${method} ${pathname}`) const result = await this.#markEndpointCovered(method, pathname, response.status, queryParams) this.log(`Endpoint coverage result: ${result}`) } catch (error) { console.warn(`Failed to track Axios request: ${config.method} ${config.url}`, error) } return response } // Also patch the convenience methods const methodsToTrack = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] methodsToTrack.forEach(method => { if (typeof axiosInstance[method] === 'function') { this.originalRequest[method] = axiosInstance[method] // Override the method to track usage axiosInstance[method] = async (url, data, config) => { this.log(`Axios ${method} called with url: ${url}`) // Call the original method const response = await this.originalRequest[method].call(axiosInstance, url, data, config) // Track this endpoint try { // Handle both full URLs and relative paths let pathname let queryParams = {} try { const parsedUrl = new URL(url) pathname = parsedUrl.pathname // Extract query parameters for (const [key, value] of parsedUrl.searchParams.entries()) { queryParams[key] = value } } catch { // If URL parsing fails, assume it's a path const [path, query] = url.split('?') pathname = path // Extract query parameters if present if (query) { const params = new URLSearchParams(query) for (const [key, value] of params.entries()) { queryParams[key] = value } } } this.log(`Tracking Axios ${method}: ${method.toUpperCase()} ${pathname}`) const result = await this.#markEndpointCovered(method.toUpperCase(), pathname, response.status, queryParams) this.log(`Endpoint coverage result: ${result}`) } catch (error) { console.warn(`Failed to track Axios ${method}: ${method} ${url}`, error) } return response } } }) this.log('Axios instance patched successfully') } /** * Patch global fetch function * @param {Function} fetchFn - Global fetch function * @description Patches global fetch function to track API coverage */ #patchFetch(fetchFn) { this.fetch = true // Store original fetch for later restoration this.originalRequest = { fetch: fetchFn } // Override the global fetch function // @ts-ignore - We're intentionally replacing the global fetch with a compatible function global.fetch = async (input, init) => { // Call the original fetch const response = await this.originalRequest.fetch(input, init) // Track this endpoint try { const method = (init?.method || 'GET').toUpperCase() const url = typeof input === 'string' ? input : input.url // Handle both full URLs and relative paths let pathname let queryParams = {} try { const parsedUrl = new URL(url) pathname = parsedUrl.pathname // Extract query parameters for (const [key, value] of parsedUrl.searchParams.entries()) { queryParams[key] = value } } catch { // If URL parsing fails, assume it's a path const [path, query] = url.split('?') pathname = path // Extract query parameters if present if (query) { const params = new URLSearchParams(query) for (const [key, value] of params.entries()) { queryParams[key] = value } } } this.log(`Tracking request: ${method} ${pathname}`) await this.#markEndpointCovered(method, pathname, response.status, queryParams) } catch (error) { console.warn(`Failed to track request: ${init?.method || 'GET'} ${input}`, error) } return response } } /** * Mark an endpoint as covered * @param {string} method - HTTP method (GET, POST, etc.) * @param {string} path - Request path (/api/users, etc.) * @param {number} statusCode - HTTP status code * @param {Object} [queryParams={}] - Query parameters used in the request * @returns {Promise<boolean>} - Whether the endpoint was successfully marked as covered */ async #markEndpointCovered(method, path, statusCode, queryParams = {}) { this.log(`Marking endpoint as covered: ${method} ${path} (status: ${statusCode})`) // Create a copy of the path to avoid modifying the parameter let normalizedPath = path.replace(/\/$/, '') if (this.basePath && normalizedPath.startsWith(this.basePath)) { normalizedPath = normalizedPath.substring(this.basePath.length) } if (!normalizedPath.startsWith('/')) { normalizedPath = '/' + normalizedPath } const exactKey = `${normalizedPath} ${method}` this.log(`Looking for exact match: ${exactKey}`) let matchedEndpointKey = null if (this.endpoints.has(exactKey)) { matchedEndpointKey = exactKey this.log(`Found exact match: ${exactKey}`) } else { this.log(`No exact match, trying regex matching for ${normalizedPath} ${method}`) for (const [key, endpoint] of this.endpoints.entries()) { // First check if the method matches exactly if (endpoint.method !== method) { this.log(`Method mismatch: ${endpoint.method} !== ${method}`) continue } // Then check if the path matches if (endpoint.pathRegex.test(normalizedPath)) { matchedEndpointKey = key this.log(`Found regex match: ${key}`) break } } } if (!matchedEndpointKey) { this.log(`No match found for: ${method} ${normalizedPath}`) return false } const endpoint = this.endpoints.get(matchedEndpointKey) endpoint.covered = true if (!this.coverageMap.has(matchedEndpointKey)) { this.coverageMap.set(matchedEndpointKey, { path: endpoint.path, method: endpoint.method, statuses: new Map() }) this.log(`Added new endpoint to coverage map: ${matchedEndpointKey}`) } const record = this.coverageMap.get(matchedEndpointKey) record.statuses.set(statusCode, (record.statuses.get(statusCode) || 0) + 1) this.log(`Updated coverage for ${matchedEndpointKey} with status ${statusCode}`) this.log(`Current coverage map size: ${this.coverageMap.size}`) // Track query parameters if any were used if (Object.keys(queryParams).length > 0) { if (!this.queryParamsCoverage.has(matchedEndpointKey)) { this.queryParamsCoverage.set(matchedEndpointKey, new Set()) } const paramSet = this.queryParamsCoverage.get(matchedEndpointKey) for (const paramName of Object.keys(queryParams)) { paramSet.add(paramName) this.log(`Marked query parameter as covered: ${matchedEndpointKey} - ${paramName}`) } } await this.#saveHistory() return true } /** * Manually register a request as covered * @param {string} method - HTTP method (GET, POST, etc.) * @param {string} path - Request path (/api/users, etc.) * @param {Object} response - Response object with status() or status property * @param {Object} [queryParams={}] - Query parameters used in the request * @param {string} [coverage='basic'] - Coverage type ('basic', 'detailed') * @returns {Promise<boolean>} - Whether the registration was successful * @example * apiCoverage.registerRequest('GET', '/api/users', { status: 200 }, { page: 1 }); */ async registerRequest(method, path, response, queryParams = {}, coverage = 'basic') { this.coverageType = coverage this.log(`Manually registering: ${method} ${path}`) const statusCode = typeof response.status === 'function' ? response.status() : response.status return await this.#markEndpointCovered(method.toUpperCase(), path, statusCode, queryParams) } /** * Register requests from a Postman collection * @param {Object} params - Configuration options * @param {string} params.collectionPath - Path to Postman collection file * @param {'basic' | 'detailed'} params.coverage - Coverage type ('basic', 'detailed') * @returns {Promise<void>} * @example * await apiCoverage.registerPostmanRequests({ * collectionPath: './collection.json', * coverage: 'detailed' * }); */ async registerPostmanRequests(params = { collectionPath: '', coverage: 'basic' }) { const { collectionPath, coverage } = params this.log(`Registering requests from Postman collection: ${collectionPath}`) try { const requests = parser.parseCollection(collectionPath) // Process requests sequentially to avoid race conditions for (const entry of requests) { for (const [status, count] of Object.entries(entry.statuses)) { await this.registerRequest( entry.method, entry.path, { status: parseInt(status) }, entry.queryParams.reduce((obj, param) => ({ ...obj, [param]: 'value' }), {}), coverage ) } } this.log('Successfully registered Postman collection requests') } catch (error) { throw new Error(`Failed to register Postman requests: ${error.message}`) } } /** * Stop tracking API requests and restore original methods * @param {Object} [client] - HTTP client instance (optional, will use stored instance if not provided) * @returns {boolean} - Whether tracking was successfully stopped * @example * apiCoverage.stopTracking(); // Stop tracking with stored client * apiCoverage.stopTracking(axios); // Stop tracking with specific client */ stopTracking(client) { if (!this.originalRequest) { this.log('No tracking in progress, ignoring stopTracking call') return false } const targetClient = client || this.clientInstance if (!targetClient) { this.log('No client instance available to restore') return false } this.log(`Stopping tracking for client type: ${this.clientType}`) // Restore original methods based on client type switch (this.clientType?.toLowerCase()) { case 'playwright': // Restore original methods for (const [method, originalFn] of Object.entries(this.originalRequest)) { targetClient[method] = originalFn } break case 'axios': // Restore original methods targetClient.request = this.originalRequest.request const methodsToRestore = ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'] methodsToRestore.forEach(method => { if (this.originalRequest[method]) { targetClient[method] = this.originalRequest[method] } }) break case 'fetch': // Restore original fetch // @ts-ignore - We're intentionally restoring the global fetch global.fetch = this.originalRequest.fetch break default: this.log(`Unknown client type: ${this.clientType}`) return false } this.originalRequest = null this.clientType = null this.clientInstance = null this.log('Tracking stopped successfully') return true } /** * Get coverage statistics * @returns {Object} Coverage statistics * @property {number} total - Total number of endpoints * @property {number} covered - Number of covered endpoints * @property {number} percentage - Coverage percentage * @property {Array<Object>} coveredDetails - Details of covered endpoints * @property {Array<Object>} uncoveredEndpoints - List of uncovered endpoints * @example * const stats = apiCoverage.getCoverageStats(); * console.log(`Coverage: ${stats.percentage}%`); */ getCoverageStats() { const total = this.endpoints.size const covered = this.coverageMap.size const percentage = total > 0 ? (covered / total) * 100 : 0 this.log(`Coverage stats: ${covered}/${total} (${percentage}%)`) const details = [] for (const [key, info] of this.coverageMap.entries()) { const statusCounts = {} for (const [status, count] of info.statuses.entries()) { statusCounts[status] = count } details.push({ method: info.method, path: info.path, statuses: statusCounts }) } return { total, covered, percentage: percentage, coveredDetails: details, uncoveredEndpoints: this.#getUncoveredEndpoints() } } /** * Get list of uncovered endpoints * @returns {Array<Object>} List of uncovered endpoints * @property {string} path - Endpoint path * @property {string} method - HTTP method * @property {string} operationId - Operation ID from OpenAPI spec */ #getUncoveredEndpoints() { const uncovered = [] for (const [key, endpoint] of this.endpoints.entries()) { if (!endpoint.covered) { uncovered.push({ path: endpoint.path, method: endpoint.method, operationId: endpoint.operation.operationId || 'Unknown' }) } } return uncovered } /** * Safely write a file, creating directories if they don't exist * @param {string} filePath - Path to the file to write * @param {string} content - Content to write to the file S */ async #safeWriteFile(filePath, content) { const dir = path.dirname(filePath) if (!fs.existsSync(dir)) { await fs.promises.mkdir(dir, { recursive: true }) this.log(`Created directory: ${dir}`) } await fs.promises.writeFile(filePath, content) this.log(`File written successfully: ${filePath}`) } /** * Acquire a lock for file operations * @param {string} lockFilePath - Path to the lock file * @param {number} [maxRetries=5] - Maximum number of retries * @param {number} [retryDelay=1000] - Delay between retries in milliseconds * @returns {Promise<boolean>} - Whether lock was acquired successfully */ async #acquireLock(lockFilePath, maxRetries = 5, retryDelay = 1000) { let retries = 0 while (retries < maxRetries) { try { await fs.promises.writeFile(lockFilePath, process.pid.toString(), { flag: 'wx' }) this.log(`Lock acquired for ${lockFilePath}`) return true } catch (error) { if (error.code === 'EEXIST') { try { const lockContent = await fs.promises.readFile(lockFilePath, 'utf-8') const lockPid = parseInt(lockContent, 10) try { process.kill(lockPid, 0) await new Promise(resolve => setTimeout(resolve, retryDelay)) retries++ continue } catch (killError) { await fs.promises.unlink(lockFilePath) continue } } catch (readError) { retries++ continue } } throw error } } return false } /** * Release a file lock * @param {string} lockFilePath - Path to the lock file * @returns {Promise<void>} */ async #releaseLock(lockFilePath) { try { await fs.promises.unlink(lockFilePath) this.log(`Lock released for ${lockFilePath}`) } catch (error) { this.log(`Warning: Failed to release lock: ${error.message}`) } } /** * Save current coverage state to a history file * @returns {Promise<void>} * @throws {Error} - Throws an error if file operations fail */ async #saveHistory() { if (!this.JSON_REPORT_HISTORY_PATH) { throw new Error('JSON report history path is not set in config.json') } this.log(`Saving coverage history to ${this.JSON_REPORT_HISTORY_PATH}`) const lockFilePath = `${this.JSON_REPORT_HISTORY_PATH}.lock` const lockAcquired = await this.#acquireLock(lockFilePath) if (!lockAcquired) { throw new Error('Failed to acquire lock for history file') } try { const history = [] for (const [key, info] of this.coverageMap.entries()) { const statuses = {} for (const [status, count] of info.statuses.entries()) { statuses[status] = count } // Include query parameters in history const queryParams = this.queryParamsCoverage.has(key) ? Array.from(this.queryParamsCoverage.get(key)) : [] history.push({ method: info.method, path: info.path, statuses, queryParams }) } this.log(`Writing ${history.length} entries to history file`) if (!fs.existsSync(this.JSON_REPORT_HISTORY_PATH)) { await this.#safeWriteFile(this.JSON_REPORT_HISTORY_PATH, JSON.stringify(history, null, 2)) this.log(`Created new history file with ${history.length} entries`) } else { const existing = JSON.parse(await fs.promises.readFile(this.JSON_REPORT_HISTORY_PATH, 'utf-8')) const merged = this.#mergeHistory(existing, history) await this.#safeWriteFile(this.JSON_REPORT_HISTORY_PATH, JSON.stringify(merged, null, 2)) this.log(`Merged with existing history file, total entries: ${merged.length}`) } this.log(`Coverage history saved to ${this.JSON_REPORT_HISTORY_PATH}`) } finally { await this.#releaseLock(lockFilePath) } } /** * Merge existing and new history data * @param {Array<Object>} existing - Existing history data * @param {Array<Object>} current - Current history data * @returns {Array<Object>} Merged history data */ #mergeHistory(existing, current) { const map = new Map() // First, process existing entries for (const entry of existing) { const key = `${entry.method} ${entry.path}` map.set(key, { ...entry, statuses: { ...entry.statuses }, queryParams: entry.queryParams || [] }) } // Then, merge with current entries for (const entry of current) { const key = `${entry.method} ${entry.path}` if (!map.has(key)) { map.set(key, { ...entry, statuses: { ...entry.statuses }, queryParams: entry.queryParams || [] }) } else { const existingEntry = map.get(key) for (const [status, count] of Object.entries(entry.statuses)) { existingEntry.statuses[status] = count } // Merge query parameters if (entry.queryParams) { existingEntry.queryParams = [...new Set([...existingEntry.queryParams, ...entry.queryParams])] } } } return Array.from(map.values()) } /** * Generate coverage reports (JSON and HTML) from history file * @returns {Promise<Object>} Generated report object * @throws {Error} - Throws an error if file operations fail * @example * await apiCoverage.generateReport(); */ async generateReport() { if (!this.BASE_PATH) { throw new Error('Paths to coverage reports are not set in config.json') } const history = await this.#readHistory() const { covered, statusCountsMap, queryParamsMap } = this.#processHistory(history) const { endpoints, totalCoverage, totalEndpoints } = this.#buildEndpointsData(covered, statusCountsMap, queryParamsMap) const overallCoverage = this.#calculateOverallCoverage(totalCoverage, totalEndpoints) const report = this.#buildReport(endpoints, overallCoverage) await this.#writeReport(report) await this.#generateHtmlReport() return report } async #readHistory() { try { if (!fs.existsSync(this.JSON_REPORT_HISTORY_PATH)) { await this.#safeWriteFile(this.JSON_REPORT_HISTORY_PATH, JSON.stringify([], null, 2)) this.log(`Created new empty history file at ${this.JSON_REPORT_HISTORY_PATH}`) return [] } return JSON.parse(await fs.promises.readFile(this.JSON_REPORT_HISTORY_PATH, 'utf-8')) } catch (err) { throw new Error(`Failed to read history: ${err.message}`) } } #processHistory(history) { const covered = new Set() const statusCountsMap = new Map() const queryParamsMap = new Map() for (const entry of history) { const key = `${entry.path} ${entry.method}` covered.add(key) if (!statusCountsMap.has(key)) { statusCountsMap.set(key, new Map()) } const statusMap = statusCountsMap.get(key) for (const [status, count] of Object.entries(entry.statuses)) { const statusCode = parseInt(status, 10) statusMap.set(statusCode, count) } if (entry.queryParams && entry.queryParams.length > 0) { if (!queryParamsMap.has(key)) { queryParamsMap.set(key, new Set()) } const paramSet = queryParamsMap.get(key) for (const param of entry.queryParams) { paramSet.add(param) } } } return { covered, statusCountsMap, queryParamsMap } } #buildEndpointsData(covered, statusCountsMap, queryParamsMap) { const endpoints = [] let totalCoverage = 0 let totalEndpoints = 0 for (const [key, endpoint] of this.endpoints.entries()) { const endpointData = this.#processEndpoint(key, endpoint, covered, statusCountsMap, queryParamsMap) endpoints.push(endpointData) totalCoverage += endpointData.totalCoverage totalEndpoints++ } return { endpoints, totalCoverage, totalEndpoints } } #processEndpoint(key, endpoint, covered, statusCountsMap, queryParamsMap) { const [path, method] = key.split(' ') const endpointKey = `${path} ${method}` const isCovered = covered.has(endpointKey) const { statusCodes, statusCodeCoverage } = this.#processStatusCodes(endpoint, endpointKey, statusCountsMap) const { queryParameters, queryParamCoverage } = this.#processQueryParams(endpoint, endpointKey, queryParamsMap) const endpointCoverage = this.#calculateEndpointCoverage(isCovered, statusCodeCoverage, queryParamCoverage, endpoint) return { name: path, method, summary: endpoint.operation?.summary || 'No summary', coverage: isCovered ? 'COVERED' : 'UNCOVERED', totalCases: statusCodes.reduce((sum, sc) => sum + (sc.totalCases || 0), 0), statusCodes, queryParameters, requestCoverage: 'MISSING', totalCoverage: endpointCoverage, totalCoverageHistory: [ { createdAt: new Date().toISOString(), totalCoverage: endpointCoverage || 0.0 } ] } } #processStatusCodes(endpoint, endpointKey, statusCountsMap) { const statusCodes = [] let statusCodeCoverage = 0 let totalStatusCodes = 0 if (endpoint.operation && endpoint.operation.responses) { totalStatusCodes = Object.keys(endpoint.operation.responses).length let coveredStatusCodes = 0 for (const [statusCode, response] of Object.entries(endpoint.operation.responses)) { const statusCodeNum = statusCode === 'default' ? 'default' : parseInt(statusCode, 10) const statusCount = statusCountsMap.get(endpointKey)?.get(statusCodeNum) || 0 const isStatusCovered = statusCount > 0 if (isStatusCovered) { coveredStatusCodes++ } statusCodes.push({ value: statusCodeNum, totalCases: statusCount, description: response.description || 'No description', responseCoverage: isStatusCovered ? 'COVERED' : 'UNCOVERED' }) } statusCodeCoverage = totalStatusCodes > 0 ? (coveredStatusCodes / totalStatusCodes) * 100 : 0 } return { statusCodes, statusCodeCoverage } } #processQueryParams(endpoint, endpointKey, queryParamsMap) { const queryParameters = [] let queryParamCoverage = 0 let totalQueryParams = 0 let coveredQueryParams = 0 if (endpoint.operation && endpoint.operation.parameters) { totalQueryParams = endpoint.operation.parameters.filter(param => param.in === 'query').length for (const param of endpoint.operation.parameters) { if (param.in === 'query') { const isParamCovered = queryParamsMap.has(endpointKey) && queryParamsMap.get(endpointKey).has(param.name) if (isParamCovered) { coveredQueryParams++ } queryParameters.push({ name: param.name, coverage: isParamCovered ? 'COVERED' : 'UNCOVERED' }) } } queryParamCoverage = totalQueryParams > 0 ? (coveredQueryParams / totalQueryParams) * 100 : 0 } return { queryParameters, queryParamCoverage } } #calculateEndpointCoverage(isCovered, statusCodeCoverage, queryParamCoverage, endpoint) { if (this.coverageType === 'detailed') { if (isCovered && endpoint.operation.responses && endpoint.operation.parameters) { const baseCoverage = 40 const weightedStatusCoverage = statusCodeCoverage * 0.4 const weightedQueryParamCoverage = queryParamCoverage * 0.2 return baseCoverage + weightedStatusCoverage + weightedQueryParamCoverage } return isCovered ? 100 : 0 } return isCovered ? 100 : 0 } #calculateOverallCoverage(totalCoverage, totalEndpoints) { return totalEndpoints > 0 ? +parseFloat(totalCoverage / totalEndpoints).toFixed(1) : 0.0 } #buildReport(endpoints, overallCoverage) { const services = this.config?.services.map(service => ({ key: service?.key, name: service?.name, tags: service?.tags, repository: service?.repository, swaggerUrl: service?.swaggerUrl, swaggerFile: service?.swaggerFile })) this.currentService = this?.config.services.find( service => service?.swaggerUrl === this.config.source || service?.swaggerFile === this.config.source ) return { config: { services }, createdAt: new Date().toISOString(), servicesCoverage: { [this.currentService?.key]: { endpoints, totalCoverage: overallCoverage, totalCoverageHistory: [ { createdAt: new Date().toISOString(), totalCoverage: overallCoverage || 0.0 } ] } } } } async #writeReport(report) { try { let existingReport = {} if (fs.existsSync(this.JSON_REPORT_PATH)) { existingReport = JSON.parse(await fs.promises.readFile(this.JSON_REPORT_PATH, 'utf-8')) } const mergedConfig = { services: [...(existingReport.config?.services || []), ...(report.config.services || [])].filter( (service, index, self) => index === self.findIndex(s => s.key === service.key) ) } const mergedServicesCoverage = { ...(existingReport.servicesCoverage || {}), ...report.servicesCoverage } for (const service of mergedConfig.services) { if (!mergedServicesCoverage[service.key]) { mergedServicesCoverage[service.key] = { endpoints: [], totalCoverage: 0, totalCoverageHistory: [ { createdAt: new Date().toISOString(), totalCoverage: 0 } ] } } } const mergedReport = { config: mergedConfig, createdAt: new Date().toISOString(), servicesCoverage: mergedServicesCoverage } await this.#mergeStats(mergedReport) return await this.#safeWriteFile(this.JSON_REPORT_PATH, JSON.stringify(mergedReport, null, 2)) } catch (err) { throw new Error(`Failed to merge and write report: ${err.message}`) } } async #mergeStats(report) { const normalizedServiceKey = this.currentService.key.replace(/[^a-zA-Z0-9]/g, '') this.uniqueStatsPath = this.BASE_PATH + `/${normalizedServiceKey}.json` try { const currentRunStats = report.servicesCoverage[this.currentService.key].totalCoverageHistory const lastCurrentRunRecord = currentRunStats[currentRunStats.length - 1] if (!fs.existsSync(this.uniqueStatsPath)) { return this.#safeWriteFile(this.uniqueStatsPath, JSON.stringify([lastCurrentRunRecord], null, 2)) } const existingStats = JSON.parse(await fs.promises.readFile(this.uniqueStatsPath, 'utf-8')) const tenMinutes = new Date(Date.now() - 10 * 60 * 1000) // 10 minutes ago const filteredStats = existingStats.filter(record => { const recordDate = new Date(record.createdAt) return recordDate < tenMinutes }) const mergedStats = [...filteredStats, lastCurrentRunRecord] report.servicesCoverage[this.currentService.key].totalCoverageHistory = mergedStats return this.#safeWriteFile(this.uniqueStatsPath, JSON.stringify(mergedStats, null, 2)) } catch (err) { throw new Error('ERROR adding previous run stats ' + err.message) } } async #generateHtmlReport() { try { await fs.promises.cp(this.TEMPLATE_PATH, this.REPORT_PATH, { recursive: true }) const report = await fs.promises.readFile(this.JSON_REPORT_PATH, 'utf-8') let html = await fs.promises.readFile(this.REPORT_PATH, 'utf-8') html = html.replace( /<script id="state" type="application\/json">[\s\S]*?<\/script>/, `<script id="state" type="application/json">${report}</script>` ) // await fs.promises.unlink(this.JSON_REPORT_HISTORY_PATH) return this.#safeWriteFile(this.REPORT_PATH, html) } catch (err) { throw new Error('ERROR copying template HTML file ' + err.message) } } /** * Reset coverage tracking (useful between test runs) * @returns {void} * @example * apiCoverage.resetCoverage(); // Reset all coverage data */ resetCoverage() { this.coverageMap = new Map() this.queryParamsCoverage = new Map() // Reset coverage status for all endpoints for (const endpoint of this.endpoints.values()) { endpoint.covered = false } this.log('Coverage tracking reset') } }