UNPKG

cumulocity-cypress

Version:
364 lines (363 loc) 15 kB
import * as fs from "fs"; import * as path from "path"; import * as glob from "glob"; import { pactId, } from "../c8ypact"; import { safeStringify } from "../../util"; import { C8yPactDefaultFileAdapter } from "./fileadapter"; import { removeBaseUrlFromString } from "../../url"; /** * C8yPactHARFileAdapter converts between C8yPact format and HAR (HTTP Archive) format. * This allows using external HAR tooling with C8yPact recordings. * * This adapter extends C8yPactDefaultFileAdapter to reuse folder management and utility * methods, but only supports .har file extension for reading and writing. * * When saving, pacts are converted to HAR format. When loading, HAR files are converted * back to C8yPact format. Some metadata may be stored in the comment fields to preserve * C8yPact-specific information. */ export class C8yPactHARFileAdapter extends C8yPactDefaultFileAdapter { constructor(folder) { // Call parent constructor without JavaScript support super(folder, { enableJavaScript: false, id: "harfileadapter" }); this.id = "harfileadapter"; // Override enabled extensions to only support HAR files this.fileExtension = "har"; this.enabledExtensions = [`.${this.fileExtension}`]; } description() { return `C8yPactHarFileAdapter: ${this.folder}`; } savePact(pact) { this.createFolderRecursive(this.folder); const pId = pactId(pact.id); if (pId == null) { this.log(`savePact() - invalid pact id ${pact.id} -> ${pId}`); return; } const file = path.join(this.folder, `${pId}.${this.fileExtension}`); this.log(`savePact() - write ${file} (${pact.records?.length || 0} records)`); try { const har = this.pactToHAR(pact); fs.writeFileSync(file, safeStringify(har, 2), "utf-8"); } catch (error) { console.error(`Failed to save pact as HAR.`, error); } } /** * Override parent's loadPactFromFile to handle HAR format conversion. * This is called by parent's loadPactObjects for each .har file found. */ loadPactFromFile(filePath) { if (!fs.existsSync(filePath)) { this.log(`loadPactFromFile() - file does not exist: ${filePath}`); return null; } const extension = path.extname(filePath).toLowerCase(); // Only handle .har files if (extension !== `.${this.fileExtension}`) { this.log(`loadPactFromFile() - file extension ${extension} is not supported: ${filePath}`); return null; } try { const harContent = fs.readFileSync(filePath, "utf-8"); const har = JSON.parse(harContent); const filename = path.basename(filePath, `.${this.fileExtension}`); const pact = this.harToPact(har, filename); if (pact) { this.log(`loadPactFromFile() - ${filePath} loaded successfully`); } return pact; } catch (error) { this.log(`loadPactFromFile() - error loading ${filePath}: ${error}`); return null; } } /** * Override parent's loadPactObjects to use simpler glob pattern for .har files. * The parent's brace expansion pattern doesn't work well with single extensions. */ loadPactObjects() { this.log(`loadPactObjects() - ${this.folder}`); if (!this.folder || !fs.existsSync(this.folder)) { this.log(`loadPactObjects() - ${this.folder} does not exist`); return []; } const harFiles = glob.sync(path.join(this.folder, `*.${this.fileExtension}`)); this.log(`loadPactObjects() - reading ${harFiles.length} .${this.fileExtension} files from ${this.folder}`); const pactObjects = harFiles .map((file) => { try { return this.loadPactFromFile(file); } catch (error) { this.log(`loadPactObjects() - error loading ${file}: ${error}`); return null; } }) .filter(Boolean); this.log(`loadPactObjects() - loaded ${pactObjects.length} valid pact objects`); return pactObjects; } /** * Convert a C8yPact object to HAR format */ pactToHAR(pact) { const entries = (pact.records || []).map((record) => { const request = record.request; const response = record.response; // Parse URL to extract query string parameters and ensure absolute URL const requestUrl = request.url || ""; let absoluteUrl = requestUrl; let queryString = []; try { // Parse URL with baseUrl to ensure it's absolute const urlObj = new URL(requestUrl, pact.info?.baseUrl || "http://localhost"); absoluteUrl = urlObj.href; queryString = Array.from(urlObj.searchParams.entries()).map(([name, value]) => ({ name, value })); } catch { // If URL parsing fails, try to make it absolute if it starts with / if (requestUrl.startsWith("/") && pact.info?.baseUrl) { try { const baseUrl = pact.info.baseUrl.replace(/\/$/, ""); absoluteUrl = baseUrl + requestUrl; } catch { // Keep original URL if all fails } } } // Convert headers from object to HAR format const requestHeaders = request.headers ? Object.entries(request.headers).flatMap(([name, value]) => { if (Array.isArray(value)) { return value.map((v) => ({ name, value: String(v) })); } return [{ name, value: String(value) }]; }) : []; const responseHeaders = response.headers ? Object.entries(response.headers).flatMap(([name, value]) => { if (Array.isArray(value)) { return value.map((v) => ({ name, value: String(v) })); } return [{ name, value: String(value) }]; }) : []; // Handle request body let postData; let requestBodySize = 0; if (request.body != null || request.$body != null) { const bodyData = request.$body || request.body; const headers = request.headers; const contentType = headers?.["content-type"] || headers?.["Content-Type"] || "application/json"; const bodyText = typeof bodyData === "string" ? bodyData : safeStringify(bodyData); requestBodySize = bodyText ? bodyText.length : 0; postData = { mimeType: String(contentType), text: bodyText, }; } // Handle response body const responseBody = response.$body || response.body; const respHeaders = response.headers; const responseContentType = respHeaders?.["content-type"] || respHeaders?.["Content-Type"] || "application/json"; const responseText = typeof responseBody === "string" ? responseBody : safeStringify(responseBody); const responseContent = { size: responseText ? responseText.length : 0, mimeType: String(responseContentType), text: responseText, }; // Create the HAR entry with C8yPact metadata in comments const entry = { startedDateTime: new Date().toISOString(), time: response.duration || 0, request: { method: String(request.method || "GET").toUpperCase(), url: absoluteUrl, httpVersion: "HTTP/1.1", cookies: [], headers: requestHeaders, queryString: queryString, postData: postData, headersSize: -1, bodySize: requestBodySize, }, response: { status: response.status || 200, statusText: response.statusText || "", httpVersion: "HTTP/1.1", cookies: [], headers: responseHeaders, content: responseContent, redirectURL: "", headersSize: -1, bodySize: responseContent.size, }, cache: {}, timings: { send: -1, wait: response.duration || 0, receive: -1, }, comment: safeStringify({ c8ypact: { id: record.id, auth: record.auth, options: record.options, createdObject: record.createdObject, }, }), }; return entry; }); const har = { log: { version: "1.2", creator: { name: pact.info?.producer ? typeof pact.info.producer === "string" ? pact.info.producer : pact.info.producer.name : "C8yPact", version: pact.info?.version?.c8ypact || "1.0.0", }, entries: entries, comment: safeStringify({ c8ypact: { id: pact.id, info: { ...pact.info, // Don't duplicate large fields that are in entries }, }, }), }, }; return har; } /** * Convert a HAR format to C8yPact object */ harToPact(har, id) { try { // Extract C8yPact metadata from comment if available let pactMetadata = {}; try { if (har.log.comment) { const parsed = JSON.parse(har.log.comment); pactMetadata = parsed.c8ypact || {}; } } catch { // Ignore comment parsing errors } const baseUrl = pactMetadata.info?.baseUrl; const pactId = pactMetadata.id || id; const records = har.log.entries.map((entry) => { // Extract C8yPact metadata from entry comment if available let recordMetadata = {}; try { if (entry.comment) { const parsed = JSON.parse(entry.comment); recordMetadata = parsed.c8ypact || {}; } } catch { // Ignore comment parsing errors } // Convert HAR headers to object format const requestHeaders = {}; entry.request.headers.forEach((header) => { requestHeaders[header.name] = header.value; }); const responseHeaders = {}; entry.response.headers.forEach((header) => { responseHeaders[header.name] = header.value; }); // Parse request body let requestBody; if (entry.request.postData?.text) { try { // Try to parse as JSON if (entry.request.postData.mimeType.includes("application/json")) { requestBody = JSON.parse(entry.request.postData.text); } else { requestBody = entry.request.postData.text; } } catch { requestBody = entry.request.postData.text; } } // Parse response body let responseBody; if (entry.response.content.text) { try { // Try to parse as JSON if (entry.response.content.mimeType.includes("application/json")) { responseBody = JSON.parse(entry.response.content.text); } else { responseBody = entry.response.content.text; } } catch { responseBody = entry.response.content.text; } } return { id: recordMetadata.id, request: { method: entry.request.method, url: removeBaseUrlFromString(entry.request.url, baseUrl), headers: requestHeaders, body: requestBody, }, response: { status: entry.response.status, statusText: entry.response.statusText, headers: responseHeaders, body: responseBody, duration: entry.time, isOkStatusCode: entry.response.status >= 200 && entry.response.status < 300, }, auth: recordMetadata.auth, options: recordMetadata.options, createdObject: recordMetadata.createdObject, }; }); // Reconstruct pact info from HAR metadata const info = { ...pactMetadata.info, id: pactId, producer: { name: har.log.creator.name, version: har.log.creator.version, }, version: { c8ypact: har.log.creator.version }, baseUrl: pactMetadata.info?.baseUrl || "", }; const pact = { id: pactId, info: info, records: records, }; return pact; } catch (error) { this.log(`harToPact() - error converting HAR to pact: ${error}`); return null; } } }