UNPKG

cypress-autorecord

Version:

It simplifies mocking by auto-recording/stubbing HTTP interactions and automate the process of updating/deleting recordings.

280 lines (247 loc) 9.86 kB
'use strict'; const path = require('path'); const util = require('./util'); const guidGenerator = util.guidGenerator; const sizeInMbytes = util.sizeInMbytes; const blobToPlain = util.blobToPlain; const cypressConfig = Cypress.config('autorecord') || {}; const isCleanMocks = cypressConfig.cleanMocks || false; const isForceRecord = cypressConfig.forceRecord || false; const recordTests = cypressConfig.recordTests || []; const blacklistRoutes = cypressConfig.blacklistRoutes || []; let interceptPattern = cypressConfig.interceptPattern || '*'; const interceptPatternFragments = interceptPattern.match(/\/(.*?)\/([a-z]*)?$/i); if (interceptPatternFragments) { interceptPattern = new RegExp( interceptPatternFragments[1], interceptPatternFragments[2] || "" ); } const whitelistHeaders = cypressConfig.whitelistHeaders || []; const maxInlineResponseSize = cypressConfig.maxInlineResponseSize || 70; const supportedMethods = ['get', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']; const fileName = path.basename( Cypress.spec.name, path.extname(Cypress.spec.name), ); // The replace fixes Windows path handling const fixturesFolder = Cypress.config('fixturesFolder').replace(/\\/g, '/'); const fixturesFolderSubDirectory = fileName.replace(/\./, '-'); const mocksFolder = path.join(fixturesFolder, '../mocks'); before(function() { if (isCleanMocks) { cy.task('cleanMocks'); } if (isForceRecord) { cy.task('removeAllMocks'); } }); module.exports = function autoRecord() { const whitelistHeaderRegexes = whitelistHeaders.map((str) => RegExp(str)); // For cleaning, to store the test names that are active per file const testNames = []; // For cleaning, to store the clean mocks per file const cleanMockData = {}; // Locally stores all mock data for this spec file let routesByTestId = {}; // For recording, stores data recorded from hitting the real endpoints let routes = []; // Stores any fixtures that need to be added const addFixture = {}; // Stores any fixtures that need to be removed const removeFixture = []; // For force recording, check to see if [r] is present in the test title let isTestForceRecord = false; // Timestamp for when this test was executed let timestamp = null; before(function() { // Get mock data that relates to this spec file cy.task('readFile', path.join(mocksFolder, `${fileName}.json`)).then((data) => { routesByTestId = data === null ? {} : data; }); }); beforeEach(function() { // Reset routes before each test case routes = []; cy.intercept(interceptPattern, (req) => { // This is cypress loading the page if ( Object.keys(req.headers).some((k) => k === 'x-cypress-authorization') ) { return; } req.reply((res) => { const url = req.url; const status = res.statusCode; const method = req.method; const data = res.body.constructor.name === 'Blob' ? blobToPlain(res.body) : res.body; const body = req.body; const headers = Object.entries(res.headers) .filter(([key]) => whitelistHeaderRegexes.some((regex) => regex.test(key)), ) .reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {}); // We push a new entry into the routes array // Do not rerecord duplicate requests if ( !routes.some( (route) => route.url === url && route.body === body && route.method === method && // when the response has changed for an identical request signature // add this entry as well. This is useful for polling-oriented endpoints // that can have varying responses. route.response === data, ) ) { routes.push({ url, method, status, data, body, headers }); } }); }); // check to see if test is being force recorded // TODO: change this to regex so it only reads from the beginning of the string isTestForceRecord = this.currentTest.title.includes('[r]'); this.currentTest.title = isTestForceRecord ? this.currentTest.title.split('[r]')[1].trim() : this.currentTest.title; // Load stubbed data from local JSON file // Do not stub if... // This test is being force recorded // there are no mock data for this test if ( !recordTests.includes(this.currentTest.title) && !isTestForceRecord && routesByTestId[this.currentTest.title] ) { // This is used to group routes by method type and url (e.g. { GET: { '/api/messages': {...} }}) const sortedRoutes = {}; supportedMethods.forEach((method) => { sortedRoutes[method] = {}; }); // set the browser's Date to the timestamp at which this spec's endpoints were recorded. cy.clock(routesByTestId[this.currentTest.title].timestamp, ['Date']); routesByTestId[this.currentTest.title].routes.forEach((request) => { if (!sortedRoutes[request.method][request.url]) { sortedRoutes[request.method][request.url] = []; } sortedRoutes[request.method][request.url].push(request); }); const createStubbedRoute = (method, url) => { let index = 0; const response = sortedRoutes[method][url][index]; cy.intercept( { url, method, }, (req) => { req.reply((res) => { const newResponse = sortedRoutes[method][url][index]; res.send( newResponse.status, newResponse.fixtureId ? { fixture: `${fixturesFolderSubDirectory}/${newResponse.fixtureId}.json`, } : newResponse.response, newResponse.headers, ); if (sortedRoutes[method][url].length > index + 1) { index++; } }); }, ); }; // Stub all recorded routes Object.keys(sortedRoutes).forEach((method) => { Object.keys(sortedRoutes[method]).forEach((url) => createStubbedRoute(method, url)); }); } else { // lock the browser's timestamp in place so that there is no variation with the // timestamp REST APIs use as an argument due to undeterministic page load times // which will cause varying timestamps. `cy.clock` locks the timestamp. timestamp = Date.now(); cy.clock(timestamp, ['Date']); } // Store test name if isCleanMocks is true if (isCleanMocks) { testNames.push(this.currentTest.title); } cy.clock().invoke('restore'); }); afterEach(function() { // Check to see if the current test already has mock data or if forceRecord is on if ( (!routesByTestId[this.currentTest.title] || isTestForceRecord || recordTests.includes(this.currentTest.title)) && !isCleanMocks ) { // Construct endpoint to be saved locally const endpoints = routes.map((request) => { // Check to see of mock data is too large for request header const isFileOversized = sizeInMbytes(request.data) > maxInlineResponseSize; let fixtureId; // If the mock data is too large, store it in a separate json if (isFileOversized) { fixtureId = guidGenerator(); addFixture[path.join(fixturesFolder, fixturesFolderSubDirectory, `${fixtureId}.json`)] = request.data; } return { fixtureId: fixtureId, url: request.url, method: request.method, status: request.status, headers: request.headers, body: request.body, response: isFileOversized ? undefined : request.data }; }); // Delete fixtures if we are overwriting mock data if (routesByTestId[this.currentTest.title]) { routesByTestId[this.currentTest.title].routes.forEach((route) => { // If fixtureId exist, delete the json if (route.fixtureId) { removeFixture.push(path.join(fixturesFolder, fixturesFolderSubDirectory, `${route.fixtureId}.json`)); } }); } // Store the endpoint for this test in the mock data object for this file if there are endpoints for this test if (endpoints.length > 0) { routesByTestId[this.currentTest.title] = { // since REST APIs can pass a timestamp argument, we need to keep track // of the time at which this spec was recorded so we can set the browser's Date // to that specific time so that the endpoints can be properly stubbed as the // the timestamp is part of many of the APIs' signature as well as POST body and uniquely identifies it. timestamp, routes: endpoints }; } } }); after(function() { // Transfer used mock data to new object to be stored locally if (isCleanMocks) { Object.keys(routesByTestId).forEach((testName) => { if (testNames.includes(testName)) { cleanMockData[testName] = routesByTestId[testName]; } else { routesByTestId[testName].routes.forEach((route) => { if (route.fixtureId) { cy.task('deleteFile', path.join(fixturesFolder, fixturesFolderSubDirectory, `${route.fixtureId}.json`)); } }); } }); } removeFixture.forEach((fixtureName) => cy.task('deleteFile', fixtureName)); cy.writeFile(path.join(mocksFolder, `${fileName}.json`), isCleanMocks ? cleanMockData : routesByTestId); Object.keys(addFixture).forEach((fixtureName) => { cy.writeFile(fixtureName, addFixture[fixtureName]); }); }); };