diff --git a/README.md b/README.md index ba10536..93d2a4a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ -Library (written in TypeScript) to mock REST and GraphQL requests +Library (written in TypeScript) to mock REST, GraphQL, and Websocket requests @@ -93,13 +93,13 @@ import { AppModule } from './app/app.module'; import { environment } from './environments/environment'; async function setupMocks() { - const { injectMocks, extractScenarioFromLocation } = await import( - 'data-mocks' - ); - // You could just define your mocks inline if you didn't want to import them. - const { getMocks } = await import('./path/to/your/mock/definitions'); + const { injectMocks, extractScenarioFromLocation } = await import( + 'data-mocks' + ); + // You could just define your mocks inline if you didn't want to import them. + const { getMocks } = await import('./path/to/your/mock/definitions'); - injectMocks(getMocks(), extractScenarioFromLocation(window.location)); + injectMocks(getMocks(), extractScenarioFromLocation(window.location)); } async function main() { @@ -303,6 +303,38 @@ const Component = () => { }; ``` +### Basic Websocket server mock injection + +To mock a WebSocket server you should provide a function which takes a single `Server` (provided by `mock-socket`) as a paremeter. On this mock server parameter you can set your callbacks as normal. Please not that due to limitations in the underlying websocket mocking library, the url _must_ be supplied as a string, not a regular expression + +```ts +const wsMock: WebSocketServerMock = s => { + return s.on('connection', socket => { + socket.on('message', _ => { + socket.send('hello world') + } + }); + }); +}; + +const mocks = { + default: [ + { + url: 'ws://localhost' //notice this is NOT a regular expression + method: 'WEBSOCKET', + server: wsMock, + } + ] +}; + +injectMocks(mocks, extractScenarioFromLocation(window.location)); + + +const socket = new WebSocket('ws://localhost') +socket.send('hello') + +``` + ## Exported types ### Scenarios @@ -331,6 +363,14 @@ const Component = () => { | method | string | ✅ | Must be 'GRAPHQL' to specify that this is a GraphQL mock. | | operations | Array\ | ✅ | Array of GraphQL operations for this request. | +### WebSocketMock + +| Property | Type | Required | Description | +| -------- | -------- | -------- | ------------------------------------------------------------------------------------------------------------- | +| url | string | ✅ | Regular expression that matches part of the URL. | +| method | string | ✅ | Must be 'WEBSOCKET' to specify that this is a Websocket mock. | +| server | function | ✅ | a function which takes a server as a parameter. Here you will set up the functionality of the server to mock. | + ### Mock Union type of [`HttpMock`](#HttpMock) and [`GraphQLMock`](#GraphQLMock). diff --git a/jest.config.js b/jest.config.js index ed0f2e1..2786a53 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ module.exports = { preset: 'ts-jest', - testEnvironment: 'jsdom' + testEnvironment: 'jsdom', }; diff --git a/package.json b/package.json index cbf0f49..beda6b3 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "fetch-mock": "^9.4.0", + "mock-socket": "^9.0.3", "query-string": "^5.1.1", "xhr-mock": "^2.5.1" }, diff --git a/src/mocks.test.ts b/src/mocks.test.ts index 9670564..64d6d55 100644 --- a/src/mocks.test.ts +++ b/src/mocks.test.ts @@ -1,19 +1,63 @@ -import 'isomorphic-fetch'; import axios from 'axios'; +import fetchMock from 'fetch-mock'; +import 'isomorphic-fetch'; +import XHRMock, { proxy } from 'xhr-mock'; import { - injectMocks, extractScenarioFromLocation, + injectMocks, reduceAllMocksForScenario, } from './mocks'; -import { HttpMethod, Scenarios, MockConfig } from './types'; -import XHRMock, { proxy } from 'xhr-mock'; -import fetchMock from 'fetch-mock'; +import { HttpMethod, MockConfig, Scenarios } from './types'; describe('data-mocks', () => { beforeEach(() => { fetchMock.resetHistory(); }); + describe('Websockets', () => { + const testURL = 'ws://localhost/foo'; + it('Spawns a working websocket server', async () => { + const onMessage = jest.fn(); + const onConnect = jest.fn(); + const scenarios: Scenarios = { + default: [ + { + url: testURL, + method: 'WEBSOCKET', + server: (s) => { + s.on('connection', (socket) => { + onConnect(); + socket.on('message', (req) => { + onMessage(); + socket.send(req.toString()); + s.close(); + }); + }); + }, + }, + ], + }; + injectMocks(scenarios, 'default'); + + const socket = new WebSocket(testURL); + let res; + socket.addEventListener('message', (data) => { + res = data.data; + socket.close(); + }); + + await awaitSocket(socket, WebSocket.OPEN); + expect(onConnect).toBeCalled(); + + const message = 'hello world'; + socket.send(message); + await awaitSocket(socket, WebSocket.CLOSED); + expect(onMessage).toBeCalled(); + + expect(res).toEqual(message); + }); + }); + describe('REST', () => { describe('HTTP methods', () => { const httpMethods: HttpMethod[] = [ @@ -50,7 +94,6 @@ describe('data-mocks', () => { const xhrSpy = jest.spyOn(XHRMock, httpMethod.toLowerCase() as any); injectMocks(scenarios, 'default'); - expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy.mock.calls[0][0]).toEqual(/foo/); expect(fetchSpy.mock.calls[1][0]).toEqual(/bar/); @@ -282,6 +325,8 @@ describe('data-mocks', () => { }); describe('Scenarios', () => { + const websocketServerFn = jest.fn(); + const anotherServerFn = jest.fn(); const scenarios: Scenarios = { default: [ { @@ -299,6 +344,22 @@ describe('data-mocks', () => { responseHeaders: { token: 'bar' }, }, { url: /bar/, method: 'POST', response: {}, responseCode: 200 }, + { + url: /graphql/, + method: 'GRAPHQL', + operations: [ + { + operationName: 'Query', + type: 'query', + response: { data: { test: 'data' } }, + }, + ], + }, + { + url: 'ws://localhost', + method: 'WEBSOCKET', + server: websocketServerFn, + }, ], someScenario: [ { @@ -308,6 +369,18 @@ describe('data-mocks', () => { responseCode: 401, }, { url: /baz/, method: 'POST', response: {}, responseCode: 200 }, + { + url: /graphql/, + method: 'GRAPHQL', + operations: [ + { + operationName: 'Query', + type: 'query', + response: { data: { test: 'different data' } }, + }, + ], + }, + { url: 'ws://localhost', method: 'WEBSOCKET', server: anotherServerFn }, ], }; @@ -340,6 +413,22 @@ describe('data-mocks', () => { responseHeaders: { token: 'bar' }, }, { url: /bar/, method: 'POST', response: {}, responseCode: 200 }, + { + url: /graphql/, + method: 'GRAPHQL', + operations: [ + { + operationName: 'Query', + type: 'query', + response: { data: { test: 'data' } }, + }, + ], + }, + { + url: 'ws://localhost', + method: 'WEBSOCKET', + server: websocketServerFn, + }, ]); }); @@ -367,6 +456,18 @@ describe('data-mocks', () => { responseCode: 200, }, { url: /baz/, method: 'POST', response: {}, responseCode: 200 }, + { + url: /graphql/, + method: 'GRAPHQL', + operations: [ + { + operationName: 'Query', + type: 'query', + response: { data: { test: 'different data' } }, + }, + ], + }, + { url: 'ws://localhost', method: 'WEBSOCKET', server: anotherServerFn }, ]); }); @@ -536,3 +637,15 @@ describe('data-mocks', () => { }); }); }); + +const awaitSocket = (socket, state) => { + return new Promise(function (resolve) { + setTimeout(function () { + if (socket.readyState === state) { + resolve(true); + } else { + awaitSocket(socket, state).then(resolve); + } + }, 1000); + }); +}; diff --git a/src/mocks.ts b/src/mocks.ts index c954f35..1d5520a 100644 --- a/src/mocks.ts +++ b/src/mocks.ts @@ -1,6 +1,7 @@ import fetchMock from 'fetch-mock'; import XHRMock, { delay as xhrMockDelay, proxy } from 'xhr-mock'; import { parse } from 'query-string'; +import { Server as MockServer } from 'mock-socket'; import { Scenarios, MockConfig, @@ -8,6 +9,7 @@ import { HttpMock, GraphQLMock, Operation, + WebSocketMock, } from './types'; /** @@ -43,14 +45,11 @@ export const injectMocks = ( if (!mocks || mocks.length === 0) { throw new Error('Unable to instantiate mocks'); } - - const restMocks = mocks.filter((m) => m.method !== 'GRAPHQL') as HttpMock[]; - const graphQLMocks = mocks.filter( - (m) => m.method === 'GRAPHQL' - ) as GraphQLMock[]; + const { restMocks, graphQLMocks, webSocketMocks } = getMocksByType(mocks); restMocks.forEach(handleRestMock); graphQLMocks.forEach(handleGraphQLMock); + webSocketMocks.forEach(handleWebsocketMock); if (config?.allowXHRPassthrough) { XHRMock.use(proxy); @@ -78,12 +77,19 @@ export const reduceAllMocksForScenario = ( const mocks = defaultMocks.concat(scenarioMocks); - const initialHttpMocks = mocks.filter( - ({ method }) => method !== 'GRAPHQL' - ) as HttpMock[]; - const initialGraphQlMocks = mocks.filter( - ({ method }) => method === 'GRAPHQL' - ) as GraphQLMock[]; + const { + restMocks: initialHttpMocks, + graphQLMocks: initialGraphQlMocks, + webSocketMocks: initialWebsocketMocks, + } = getMocksByType(mocks); + + const websocketMocksByUrl = initialWebsocketMocks.reduce< + Record + >((result, mock) => { + const { url } = mock; + result[url] = mock; + return result; + }, {}); const httpMocksByUrlAndMethod = initialHttpMocks.reduce< Record @@ -130,7 +136,9 @@ export const reduceAllMocksForScenario = ( } ) as GraphQLMock[]; - return (httpMocks as any).concat(graphQlMocks); + const websocketMocks = Object.values(websocketMocksByUrl); + + return [...httpMocks, ...graphQlMocks, ...websocketMocks]; }; /** @@ -281,8 +289,26 @@ function handleGraphQLMock({ url, operations }: GraphQLMock) { }); } +const handleWebsocketMock = ({ url, server }: WebSocketMock) => { + server(new MockServer(url)); +}; + /** * Adds delay (in ms) before resolving a promise. */ const addDelay = (delay: number) => new Promise((res) => setTimeout(res, delay)); + +const getMocksByType = (mocks: Mock[]) => { + const restMocks = mocks.filter( + (m) => !['GRAPHQL', 'WEBSOCKET'].includes(m.method) + ) as HttpMock[]; + const graphQLMocks = mocks.filter( + (m) => m.method === 'GRAPHQL' + ) as GraphQLMock[]; + + const webSocketMocks = mocks.filter( + (m) => m.method === 'WEBSOCKET' + ) as WebSocketMock[]; + return { restMocks, graphQLMocks, webSocketMocks }; +}; diff --git a/src/types.ts b/src/types.ts index 06271e1..2c48dde 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ export { injectMocks, reduceAllMocksForScenario, } from './mocks'; +import { Server as MockServer } from 'mock-socket'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; @@ -27,6 +28,13 @@ export type GraphQLMock = { operations: Array; }; +export type WebSocketServerMock = (mockServer: MockServer) => void; +export type WebSocketMock = { + url: string; + method: 'WEBSOCKET'; + server: WebSocketServerMock; +}; + export type Operation = { type: 'query' | 'mutation'; operationName: string; @@ -36,7 +44,7 @@ export type Operation = { delay?: number; }; -export type Mock = HttpMock | GraphQLMock; +export type Mock = HttpMock | GraphQLMock | WebSocketMock; export type Scenarios = { default: Mock[]; diff --git a/yarn.lock b/yarn.lock index 8c12ec6..3251c1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3431,6 +3431,13 @@ mkdirp@1.x: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mock-socket@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.0.3.tgz#4bc6d2aea33191e4fed5ec71f039e2bbeb95e414" + integrity sha512-SxIiD2yE/By79p3cNAAXyLQWTvEFNEzcAO7PH+DzRqKSFaplAPFjiQLmw8ofmpCsZf+Rhfn2/xCJagpdGmYdTw== + dependencies: + url-parse "^1.4.4" + mri@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.5.tgz#ce21dba2c69f74a9b7cf8a1ec62307e089e223e0" @@ -3918,6 +3925,11 @@ querystring@0.2.0, querystring@^0.2.0: resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA= +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + react-is@^16.12.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -4101,6 +4113,11 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -4739,6 +4756,14 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +url-parse@^1.4.4: + version "1.5.3" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862" + integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + url@^0.11.0: version "0.11.0" resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"