@pactflow/pact-msw-adapter
Version:
> Generate pact contracts from the recorded mock service worker interactions.
258 lines (257 loc) • 11.7 kB
JavaScript
;
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;
}
};