UNPKG

@pactflow/pact-msw-adapter

Version:

> Generate pact contracts from the recorded mock service worker interactions.

258 lines (257 loc) 11.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.convertMswMatchToPact = exports.setupPactMswAdapter = void 0; const utils_1 = require("./utils/utils"); const convertMswMatchToPact_1 = require("./convertMswMatchToPact"); Object.defineProperty(exports, "convertMswMatchToPact", { enumerable: true, get: function () { return convertMswMatchToPact_1.convertMswMatchToPact; } }); const events_1 = require("events"); const setupPactMswAdapter = ({ options: externalOptions, worker, server, }) => { if (!worker && !server) { throw new Error("Either a worker or server must be provided"); } const isWorker = worker ? !!worker : false; const mswMocker = worker ? worker : server; if (!mswMocker) { throw new Error("Could not setup either the worker or server"); } const emitter = new events_1.EventEmitter(); const options = { logger: console, ...externalOptions, timeout: externalOptions.timeout || 200, debug: externalOptions.debug || false, pactOutDir: externalOptions.pactOutDir || "./msw_generated_pacts/", }; (0, utils_1.logGroup)(`Adapter enabled${options.debug ? " on debug mode" : ""}`, { logger: options.logger }); if (options.debug) { (0, utils_1.logGroup)(["options:", options], { endGroup: true, mode: "debug", logger: options.logger }); } else { options.logger.groupEnd(); } // This can include expired requests const pendingRequests = []; // Requests waiting for their responses const unhandledRequests = []; // Requests that need to be handled const expiredRequests = []; // Requests that have expired (timeout) const orphanResponses = []; // Responses from previous tests const oldRequestIds = []; // Pending requests from previous tests const activeRequestIds = []; // Pending requests which are still valid const matches = []; // Completed request-response pairs mswMocker.events.on("request:match", ({ request, requestId }) => { if (!(0, utils_1.checkUrlFilters)({ request, requestId }, options)) return; if (options.debug) { (0, utils_1.logGroup)(["Matching request", request], { endGroup: true, mode: "debug", logger: options.logger }); } const startTime = Date.now(); pendingRequests.push({ request, requestId }); activeRequestIds.push(requestId); setTimeout(() => { const expired = { requestId, startTime, request }; const activeIdx = activeRequestIds.indexOf(requestId); if (activeIdx >= 0) { // Could be removed if completed or the test ended activeRequestIds.splice(activeIdx, 1); expiredRequests.push(expired); } emitter.emit("pact-msw-adapter:expired", expired); }, options.timeout); }); mswMocker.events.on("response:mocked", async ({ response, requestId }) => { (0, utils_1.logGroup)(JSON.stringify(response), { endGroup: true, logger: options.logger }); const reqIdx = pendingRequests.findIndex((pending) => pending.requestId === requestId); if (reqIdx < 0) return; // Filtered and (expired and cleared) requests const endTime = Date.now(); const { request } = pendingRequests.splice(reqIdx, 1)[0]; const activeReqIdx = activeRequestIds.indexOf(requestId); if (activeReqIdx < 0) { // Expired requests and responses from previous tests const oldReqId = oldRequestIds.find((id) => id === requestId); const expiredReq = expiredRequests.find((expired) => expired.requestId === requestId); if (oldReqId) { orphanResponses.push(request.url); (0, utils_1.log)(`Orphan response: ${request.url}`, { mode: "warn", group: expiredReq !== undefined, logger: options.logger, }); } if (expiredReq) { if (!oldReqId) { const pathname = new URL(request.url).pathname; (0, utils_1.log)(`Expired request to ${pathname}`, { mode: "warn", group: true, logger: options.logger, }); } expiredReq.duration = endTime - expiredReq.startTime; options.logger.info("url:", request.url); options.logger.info("timeout:", options.timeout); options.logger.info("duration:", expiredReq.duration); options.logger.groupEnd(); } return; } if (options.debug) { (0, utils_1.logGroup)(["Mocked response", response], { endGroup: true, mode: "debug", logger: options.logger }); } activeRequestIds.splice(activeReqIdx, 1); const match = { request, requestId, response, }; emitter.emit("pact-msw-adapter:match", match); matches.push(match); }); mswMocker.events.on("request:unhandled", ({ request, requestId }) => { if (!(0, utils_1.checkUrlFilters)({ request, requestId }, options)) return; unhandledRequests.push(request.url); (0, utils_1.log)(`Unhandled request: ${request.url}`, { mode: "warn", logger: options.logger }); }); return { emitter, newTest: () => { oldRequestIds.push(...activeRequestIds); activeRequestIds.length = 0; emitter.emit("pact-msw-adapter:new-test"); }, verifyTest: () => { let errors = ""; if (unhandledRequests.length) { errors += `Requests with missing msw handlers:\n ${unhandledRequests.join("\n")}\n`; unhandledRequests.length = 0; } if (expiredRequests.length) { errors += `Expired requests:\n${expiredRequests .map((expired) => ({ expired, pending: pendingRequests.find((pending) => pending.requestId === expired.requestId), })) .filter(({ expired, pending }) => expired && pending) .map(({ expired, pending }) => { const pathname = new URL(pending.request.url).pathname; return `${pathname}${expired.duration ? `took ${expired.duration}ms and` : ""} timed out after ${options.timeout}ms`; }) .join("\n")}\n`; expiredRequests.length = 0; } if (orphanResponses.length) { errors += `Orphan responses:\n${orphanResponses.join("\n")}\n`; orphanResponses.length = 0; } if (errors.length > 0) { throw new Error(`Found errors on msw requests.\n${errors}`); } }, writeToFile: async (writer = (0, utils_1.createWriter)(options)) => { // TODO - dedupe pactResults so we only have one file per consumer/provider pair // Note: There are scenarios such as feature flagging where you want more than one file per consumer/provider pair (0, utils_1.logGroup)([ "Found the following number of matches to write to a file:- " + matches.length, ], { endGroup: true, logger: options.logger }); (0, utils_1.logGroup)(JSON.stringify(matches), { endGroup: true, logger: options.logger }); let pactFiles; try { pactFiles = await transformMswToPact(matches, activeRequestIds, options, emitter); } catch (error) { (0, utils_1.logGroup)(["An error occurred parsing the JSON file", error], { logger: options.logger }); throw new Error("error generating pact files"); } if (!pactFiles) { (0, utils_1.logGroup)([ "writeToFile() was called but no pact files were generated, did you forget to await the writeToFile() method?", matches.length, ], { endGroup: true, logger: options.logger }); } pactFiles.forEach((pactFile) => { const filePath = options.pactOutDir + "/" + [pactFile.consumer.name, pactFile.provider.name].join("-") + ".json"; writer(filePath, pactFile); }); }, clear: () => { pendingRequests.length = 0; unhandledRequests.length = 0; expiredRequests.length = 0; orphanResponses.length = 0; oldRequestIds.length = 0; activeRequestIds.length = 0; matches.length = 0; emitter.emit("pact-msw-adapter:clear"); return; }, }; }; exports.setupPactMswAdapter = setupPactMswAdapter; const transformMswToPact = async (matches, activeRequestIds, options, emitter) => { try { // TODO: Lock new requests, error on clear/new-test if locked const requestsCompleted = new Promise((resolve) => { if (activeRequestIds.length === 0) { resolve(); return; } const events = [ "pact-msw-adapter:expired", "pact-msw-adapter:match", "pact-msw-adapter:new-test", "pact-msw-adapter:clear", ]; const listener = () => { if (activeRequestIds.length === 0) { events.forEach((ev) => emitter.off(ev, listener)); resolve(); } }; events.forEach((ev) => emitter.on(ev, listener)); }); await (0, utils_1.addTimeout)(requestsCompleted, "requests completed listener", options.timeout * 2); const pactFiles = []; const matchProvider = (match) => { var _a; if (typeof options.providers === "function") return options.providers(match); return (_a = Object.entries(options.providers) .find(([_, paths]) => paths.some((path) => match.request.url.includes(path)))) === null || _a === void 0 ? void 0 : _a[0]; }; const matchesByProvider = {}; matches.forEach((match) => { var _a; const provider = (_a = matchProvider(match)) !== null && _a !== void 0 ? _a : "unknown"; if (!matchesByProvider[provider]) matchesByProvider[provider] = []; matchesByProvider[provider].push(match); }); for (const [provider, providerMatches] of Object.entries(matchesByProvider)) { const pactFile = await (0, convertMswMatchToPact_1.convertMswMatchToPact)({ consumer: options.consumer, provider, matches: providerMatches, headers: { excludeHeaders: options.excludeHeaders }, }); if (pactFile) { pactFiles.push(pactFile); } } return pactFiles; } catch (err) { if (err instanceof Error) { throw err; } if (err && typeof err === "string") err = new Error(err); options.logger.groupCollapsed("%c[pact-msw-adapter] Unexpected error.", "color:coral;font-weight:bold;"); options.logger.info(err); options.logger.groupEnd(); throw err; } };