├── .npmrc
├── examples
├── hooks
│ ├── .npmrc
│ ├── .env
│ ├── src
│ │ ├── react-app-env.d.ts
│ │ ├── index.tsx
│ │ ├── setupTests.ts
│ │ ├── index.test.js
│ │ ├── styles.css
│ │ ├── App.test.tsx
│ │ └── App.tsx
│ ├── public
│ │ ├── robots.txt
│ │ ├── favicon.ico
│ │ ├── logo192.png
│ │ ├── logo512.png
│ │ ├── manifest.json
│ │ └── index.html
│ ├── tsconfig.json
│ ├── package.json
│ └── README.md
├── redux-saga
│ ├── .npmrc
│ ├── .env
│ ├── src
│ │ ├── setupTests.js
│ │ ├── __tests__
│ │ │ ├── index.test.js
│ │ │ ├── __snapshots__
│ │ │ │ └── App.test.js.snap
│ │ │ ├── App.test.js
│ │ │ └── saga.test.js
│ │ ├── App.js
│ │ ├── index.js
│ │ ├── store
│ │ │ ├── index.js
│ │ │ ├── reducer.js
│ │ │ └── saga.js
│ │ ├── Messages.js
│ │ ├── ConnectionIndicator.js
│ │ ├── test-utils.js
│ │ ├── MessageInput.js
│ │ └── styles.css
│ ├── public
│ │ ├── favicon.ico
│ │ ├── manifest.json
│ │ └── index.html
│ └── package.json
├── README.md
└── server.js
├── src
├── index.ts
├── act-compat.ts
├── queue.ts
├── websocket.ts
├── matchers.ts
└── __tests__
│ ├── matchers.test.ts
│ └── websocket.test.ts
├── .github
├── dependabot.yml
└── workflows
│ └── ci.yml
├── tsconfig.build.json
├── .npmignore
├── .babelrc
├── .gitignore
├── rollup.config.js
├── tsconfig.json
├── LICENSE.txt
├── package.json
├── CONTRIBUTING.md
└── README.md
/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/examples/hooks/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/examples/redux-saga/.npmrc:
--------------------------------------------------------------------------------
1 | package-lock=false
2 |
--------------------------------------------------------------------------------
/examples/hooks/.env:
--------------------------------------------------------------------------------
1 | BROWSER=false
2 | SKIP_PREFLIGHT_CHECK=true
3 |
--------------------------------------------------------------------------------
/examples/redux-saga/.env:
--------------------------------------------------------------------------------
1 | BROWSER=false
2 | SKIP_PREFLIGHT_CHECK=true
3 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom";
2 |
--------------------------------------------------------------------------------
/examples/hooks/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/hooks/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/examples/hooks/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replit/jest-websocket-mock/master/examples/hooks/public/favicon.ico
--------------------------------------------------------------------------------
/examples/hooks/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replit/jest-websocket-mock/master/examples/hooks/public/logo192.png
--------------------------------------------------------------------------------
/examples/hooks/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replit/jest-websocket-mock/master/examples/hooks/public/logo512.png
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from "./websocket";
2 | export { default as WS } from "./websocket";
3 | import "./matchers";
4 |
--------------------------------------------------------------------------------
/examples/redux-saga/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/replit/jest-websocket-mock/master/examples/redux-saga/public/favicon.ico
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | open-pull-requests-limit: 10
8 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "emitDeclarationOnly": true,
5 | "outDir": "lib"
6 | },
7 | "exclude": ["node_modules", "**/__tests__"]
8 | }
9 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .babelrc
2 | coverage
3 | .git
4 | .gitignore
5 | node_modules
6 | examples/build
7 | .npmignore
8 | .npmrc
9 | .prettierrc
10 | rollup.config.js
11 | tsconfig.json
12 | tsconfig.build.json
13 | *.tgz
14 |
--------------------------------------------------------------------------------
/examples/hooks/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import "./styles.css";
4 | import App from "./App";
5 |
6 | ReactDOM.render(
7 |
8 |
9 | ,
10 | document.getElementById("root")
11 | );
12 |
--------------------------------------------------------------------------------
/examples/hooks/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/__tests__/index.test.js:
--------------------------------------------------------------------------------
1 | import ReactDOM from "react-dom";
2 | import "..";
3 |
4 | jest.mock("react-dom");
5 |
6 | describe("The index", () => {
7 | it("can be imported without errors", () => {
8 | expect(ReactDOM.render).toHaveBeenCalled();
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@babel/plugin-proposal-class-properties"],
3 | "presets": ["@babel/preset-typescript"],
4 | "env": {
5 | "test": {
6 | "presets": ["@babel/preset-env", "@babel/preset-typescript"],
7 | "plugins": ["@babel/plugin-transform-runtime"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 | .pnp
4 | .pnp.js
5 |
6 | # testing
7 | coverage
8 |
9 | # production
10 | build
11 | lib
12 | jest-websocket-mock-*.tgz
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ConnectionIndicator from "./ConnectionIndicator";
3 | import Messages from "./Messages";
4 | import MessageInput from "./MessageInput";
5 |
6 | const App = () => (
7 |
8 |
9 |
10 |
11 |
12 | );
13 |
14 | export default App;
15 |
--------------------------------------------------------------------------------
/examples/hooks/src/index.test.js:
--------------------------------------------------------------------------------
1 | const { act } = require("react-dom/test-utils");
2 |
3 | describe("The index", () => {
4 | it("can be imported without errors", () => {
5 | const root = document.createElement("div");
6 | root.setAttribute("id", "root");
7 | document.body.appendChild(root);
8 |
9 | act(() => {
10 | require("./index.tsx");
11 | });
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/examples/redux-saga/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { Provider } from "react-redux";
4 | import "./styles.css";
5 | import makeStore from "./store";
6 | import App from "./App";
7 |
8 | const store = makeStore();
9 |
10 | ReactDOM.render(
11 |
12 |
13 | ,
14 | document.getElementById("root")
15 | );
16 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/store/index.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from "redux";
2 | import createSagaMiddleware from "redux-saga";
3 | import reducer from "./reducer";
4 | import saga from "./saga";
5 |
6 | export default () => {
7 | const sagaMiddleware = createSagaMiddleware();
8 | const store = createStore(reducer, applyMiddleware(sagaMiddleware));
9 | sagaMiddleware.run(saga);
10 | return store;
11 | };
12 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/Messages.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 |
4 | const Message = ({ text, side }) => {`(${side}) ${text}`}
;
5 |
6 | const Messages = ({ messages }) => (
7 |
8 | {messages.map((message, i) => (
9 |
10 | ))}
11 |
12 | );
13 |
14 | export default connect((state) => ({ messages: state.messages }))(Messages);
15 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/ConnectionIndicator.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { connect } from "react-redux";
3 |
4 | const ConnectionIndicator = ({ connected }) => (
5 |
13 | );
14 |
15 | export default connect((state) => ({ connected: state.connected }))(
16 | ConnectionIndicator
17 | );
18 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import resolve from "rollup-plugin-node-resolve";
2 | import babel from "rollup-plugin-babel";
3 | import pkg from "./package.json";
4 |
5 | export default [
6 | {
7 | input: "src/index.ts",
8 | external: ["mock-socket", "jest-diff", "@testing-library/react"],
9 | plugins: [
10 | resolve({ extensions: [".js", ".ts"] }),
11 | babel({ extensions: [".js", ".ts"] }),
12 | ],
13 | output: [
14 | { file: pkg.main, format: "cjs", exports: "named" },
15 | { file: pkg.module, format: "es" },
16 | ],
17 | },
18 | ];
19 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/__tests__/__snapshots__/App.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`The App component renders the app skeleton 1`] = `
4 |
24 | `;
25 |
--------------------------------------------------------------------------------
/examples/hooks/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react"
21 | },
22 | "include": [
23 | "src"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/examples/hooks/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "module": "commonjs",
5 | "lib": ["es2015", "dom"],
6 | "declaration": true,
7 |
8 | "strict": true,
9 | "noImplicitAny": true,
10 | "strictNullChecks": true,
11 | "strictFunctionTypes": true,
12 | "strictBindCallApply": true,
13 | "strictPropertyInitialization": true,
14 | "noImplicitThis": true,
15 | "alwaysStrict": true,
16 |
17 | "noUnusedLocals": true,
18 | "noUnusedParameters": true,
19 | "noImplicitReturns": true,
20 | "noFallthroughCasesInSwitch": true,
21 |
22 | "esModuleInterop": true,
23 | "listEmittedFiles": true
24 | },
25 | "include": ["src"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/test-utils.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render } from "@testing-library/react";
3 | import { Provider } from "react-redux";
4 | import WS from "jest-websocket-mock";
5 | import makeStore from "./store";
6 |
7 | afterEach(() => {
8 | WS.clean();
9 | });
10 |
11 | const renderWithStore = async (ui, options = {}) => {
12 | const ws = new WS("ws://localhost:8080");
13 | const store = makeStore();
14 | const rendered = render({ui}, options);
15 | await ws.connected;
16 | return {
17 | ws,
18 | ...rendered,
19 | };
20 | };
21 |
22 | export * from "@testing-library/react";
23 | export { default as userEvent } from "@testing-library/user-event";
24 | export { renderWithStore as render };
25 |
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | This folder is here to showcase testing examples of a real application.
4 |
5 | To run the tests:
6 |
7 | ```bash
8 | cd redux-saga # or `cd hooks`...
9 | npm install
10 | npm install jest-websocket-mock
11 | # Or, to run the tests against a local jest-websocket-mock build:
12 | cd ..; npm run build && npm pack; cd examples; npm install ../jest-websocket-mock-*;
13 | SKIP_PREFLIGHT_CHECK=true npm test -- --coverage
14 | ```
15 |
16 | The websocket tests are under `src/__tests__/saga.test.js` and ``src/**tests**/App.test.js`.
17 |
18 | If you want to see the app running locally:
19 |
20 | ```bash
21 | node server.js # start the server
22 | ```
23 |
24 | and in another terminal:
25 |
26 | ```bash
27 | npm start # start the client
28 | ```
29 |
--------------------------------------------------------------------------------
/src/act-compat.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * A simple compatibility method for react's "act".
3 | * If @testing-library/react is already installed, we just use
4 | * their implementation - it's complete and has useful warnings.
5 | * If @testing-library/react is *not* installed, then we just assume
6 | * that the user is not testing a react application, and use a noop instead.
7 | */
8 |
9 | type Callback = () => Promise | void | undefined;
10 | type AsyncAct = (callback: Callback) => Promise;
11 | type SyncAct = (callback: Callback) => void;
12 |
13 | let act: AsyncAct | SyncAct;
14 |
15 | try {
16 | act = require("@testing-library/react").act;
17 | } catch (_) {
18 | act = (callback: Function) => {
19 | callback();
20 | };
21 | }
22 |
23 | export default act;
24 |
--------------------------------------------------------------------------------
/examples/server.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This is a simple example server, mostly here for demonstration
3 | * purposes.
4 | * The subfolders in this directory contain actual client code with
5 | * supporting tests.
6 | **/
7 |
8 | const WebSocket = require("ws");
9 |
10 | const PORT = 8080;
11 | const server = new WebSocket.Server({ port: PORT });
12 |
13 | server.on("connection", function connection(ws, req) {
14 | ws.on("message", function incoming(message) {
15 | console.log(`[received] ${message}`);
16 | ws.send(`[echo] ${message}`);
17 | });
18 |
19 | const remoteAddress = req.connection.remoteAddress;
20 | console.log(`[connected] Client at ${remoteAddress}`);
21 | ws.send(`Hello ${remoteAddress}`);
22 | });
23 |
24 | console.log(`[start] Starting echo server on port ${PORT}.`);
25 |
--------------------------------------------------------------------------------
/src/queue.ts:
--------------------------------------------------------------------------------
1 | export default class Queue {
2 | pendingItems: Array = [];
3 | nextItemResolver!: () => void;
4 | nextItem: Promise = new Promise(
5 | (done) => (this.nextItemResolver = done)
6 | );
7 |
8 | put(item: ItemT): void {
9 | this.pendingItems.push(item);
10 | this.nextItemResolver();
11 | this.nextItem = new Promise((done) => (this.nextItemResolver = done));
12 | }
13 |
14 | get(): Promise {
15 | const item = this.pendingItems.shift();
16 | if (item) {
17 | // return the next queued item immediately
18 | return Promise.resolve(item);
19 | }
20 | let resolver: (item: ItemT) => void;
21 | const nextItemPromise: Promise = new Promise(
22 | (done) => (resolver = done)
23 | );
24 | this.nextItem.then(() => {
25 | resolver(this.pendingItems.shift() as ItemT);
26 | });
27 | return nextItemPromise;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/store/reducer.js:
--------------------------------------------------------------------------------
1 | import { createActions, handleActions, combineActions } from "redux-actions";
2 |
3 | const defaultState = {
4 | messages: [],
5 | connected: false,
6 | };
7 |
8 | export const actions = createActions({
9 | STORE_SENT_MESSAGE: text => ({ text, side: "sent" }),
10 | STORE_RECEIVED_MESSAGE: text => ({ text, side: "received" }),
11 | SEND: undefined,
12 | CONNECTION_SUCCESS: () => ({ connected: true }),
13 | CONNECTION_LOST: () => ({ connected: false }),
14 | });
15 |
16 | const reducer = handleActions(
17 | {
18 | [combineActions(actions.storeReceivedMessage, actions.storeSentMessage)]: (
19 | state,
20 | { payload }
21 | ) => ({ ...state, messages: [...state.messages, payload] }),
22 | [combineActions(actions.connectionSuccess, actions.connectionLost)]: (
23 | state,
24 | { payload: { connected } }
25 | ) => ({ ...state, connected }),
26 | },
27 | defaultState
28 | );
29 |
30 | export default reducer;
31 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/MessageInput.js:
--------------------------------------------------------------------------------
1 | import React, { PureComponent } from "react";
2 | import { connect } from "react-redux";
3 | import { actions } from "./store/reducer";
4 |
5 | class MessageInput extends PureComponent {
6 | state = { message: "" };
7 |
8 | onChange = event => this.setState({ message: event.target.value });
9 |
10 | onSubmit = event => {
11 | event.preventDefault();
12 | this.props.send(this.state.message);
13 | this.setState({ message: "" });
14 | };
15 |
16 | render() {
17 | const { message } = this.state;
18 | return (
19 |
28 | );
29 | }
30 | }
31 |
32 | export default connect(
33 | null,
34 | { send: actions.send }
35 | )(MessageInput);
36 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright 2018 Romain Bertrand
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/examples/hooks/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hooks",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^4.2.4",
7 | "@testing-library/react": "^9.5.0",
8 | "@testing-library/user-event": "^7.2.1",
9 | "@types/jest": "^24.9.1",
10 | "@types/node": "^12.12.50",
11 | "@types/react": "^16.9.43",
12 | "@types/react-dom": "^16.9.8",
13 | "react": "^16.13.1",
14 | "react-dom": "^16.13.1",
15 | "react-scripts": "3.4.1",
16 | "typescript": "^3.7.5"
17 | },
18 | "peerDependencies": {
19 | "jest-websocket-mock": "~2.0"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test",
25 | "eject": "react-scripts eject"
26 | },
27 | "eslintConfig": {
28 | "extends": "react-app"
29 | },
30 | "browserslist": {
31 | "production": [
32 | ">0.2%",
33 | "not dead",
34 | "not op_mini all"
35 | ],
36 | "development": [
37 | "last 1 chrome version",
38 | "last 1 firefox version",
39 | "last 1 safari version"
40 | ]
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/examples/hooks/src/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
9 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
10 | sans-serif;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | }
14 |
15 | .App {
16 | background-color: #282c34;
17 | min-height: 100vh;
18 | color: white;
19 | padding: 2rem;
20 | }
21 |
22 | .ConnectionIndicator {
23 | width: 2rem;
24 | height: 2rem;
25 | border-radius: 2rem;
26 | position: fixed;
27 | top: 2rem;
28 | right: 2rem;
29 | }
30 | .ConnectionIndicator--connected {
31 | background-color: green;
32 | }
33 | .ConnectionIndicator--disconnected {
34 | background-color: indianred;
35 | }
36 |
37 | .Messages {
38 | margin-bottom: 8rem;
39 | display: flex;
40 | flex-direction: column-reverse;
41 | }
42 |
43 | .MessageForm {
44 | position: fixed;
45 | bottom: 0;
46 | left: 0;
47 | right: 0;
48 | padding: 2rem;
49 | background-color: inherit;
50 | }
51 |
52 | .MessageInput {
53 | width: 100%;
54 | }
55 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | body {
6 | margin: 0;
7 | padding: 0;
8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
9 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
10 | sans-serif;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | }
14 |
15 | .App {
16 | background-color: #282c34;
17 | min-height: 100vh;
18 | color: white;
19 | padding: 2rem;
20 | }
21 |
22 | .ConnectionIndicator {
23 | width: 2rem;
24 | height: 2rem;
25 | border-radius: 2rem;
26 | position: fixed;
27 | top: 2rem;
28 | right: 2rem;
29 | }
30 | .ConnectionIndicator--connected {
31 | background-color: green;
32 | }
33 | .ConnectionIndicator--disconnected {
34 | background-color: indianred;
35 | }
36 |
37 | .Messages {
38 | margin-bottom: 8rem;
39 | display: flex;
40 | flex-direction: column-reverse;
41 | }
42 |
43 | .MessageForm {
44 | position: fixed;
45 | bottom: 0;
46 | left: 0;
47 | right: 0;
48 | padding: 2rem;
49 | background-color: inherit;
50 | }
51 |
52 | .MessageInput {
53 | width: 100%;
54 | }
55 |
--------------------------------------------------------------------------------
/examples/redux-saga/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "examples",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "react": "^16.13.1",
7 | "react-dom": "^16.13.1",
8 | "react-redux": "^7.2.0",
9 | "react-scripts": "^3.4.1",
10 | "redux": "^4.0.5",
11 | "redux-actions": "^2.6.5",
12 | "redux-saga": "^1.1.3"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "eslintConfig": {
21 | "extends": "react-app"
22 | },
23 | "browserslist": [
24 | ">0.2%",
25 | "not dead",
26 | "not ie <= 11",
27 | "not op_mini all"
28 | ],
29 | "jest": {
30 | "coverageThreshold": {
31 | "global": {
32 | "branches": 100,
33 | "functions": 100,
34 | "lines": 100,
35 | "statements": 100
36 | }
37 | }
38 | },
39 | "devDependencies": {
40 | "@testing-library/jest-dom": "^5.11.0",
41 | "@testing-library/react": "^10.4.6",
42 | "@testing-library/user-event": "^12.0.11",
43 | "mock-socket": "^9.0.3"
44 | },
45 | "peerDependencies": {
46 | "jest-websocket-mock": "~2.0"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/__tests__/App.test.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen, userEvent, fireEvent } from "../test-utils";
3 | import App from "../App";
4 |
5 | describe("The App component", () => {
6 | it("renders the app skeleton", async () => {
7 | const { container } = await render();
8 | expect(container.firstChild).toMatchSnapshot();
9 | });
10 |
11 | it("renders a green dot when successfully connected", async () => {
12 | await render();
13 | expect(screen.getByTitle("connected")).toBeInTheDocument();
14 | });
15 |
16 | it("renders a red dot when not connected", async () => {
17 | const { ws } = await render();
18 | ws.close();
19 | expect(screen.getByTitle("disconnected")).toBeInTheDocument();
20 | });
21 |
22 | it("sends the message when submitting the form", async () => {
23 | const { ws } = await render();
24 | const input = screen.getByPlaceholderText("type your message here...");
25 | userEvent.type(input, "Hello there");
26 | fireEvent.submit(input);
27 | expect(screen.getByText("(sent) Hello there")).toBeInTheDocument();
28 | await expect(ws).toReceiveMessage("Hello there");
29 |
30 | ws.send("[echo] Hello there");
31 | expect(
32 | screen.getByText("(received) [echo] Hello there")
33 | ).toBeInTheDocument();
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/examples/hooks/src/App.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen, fireEvent } from "@testing-library/react";
3 | import userEvent from "@testing-library/user-event";
4 | import WS from "jest-websocket-mock";
5 | import App from "./App";
6 |
7 | let ws: WS;
8 | beforeEach(() => {
9 | ws = new WS("ws://localhost:8080");
10 | });
11 | afterEach(() => {
12 | WS.clean();
13 | });
14 |
15 | describe("The App component", () => {
16 | it("renders a dot indicating the connection status", async () => {
17 | render();
18 | expect(screen.getByTitle("disconnected")).toBeInTheDocument();
19 |
20 | await ws.connected;
21 | expect(screen.getByTitle("connected")).toBeInTheDocument();
22 |
23 | ws.close();
24 | expect(screen.getByTitle("disconnected")).toBeInTheDocument();
25 | });
26 |
27 | it("sends and receives messages", async () => {
28 | render();
29 | await ws.connected;
30 |
31 | const input = screen.getByPlaceholderText("type your message here...");
32 | userEvent.type(input, "Hello there");
33 | fireEvent.submit(input);
34 |
35 | await expect(ws).toReceiveMessage("Hello there");
36 | expect(screen.getByText("(sent) Hello there")).toBeInTheDocument();
37 |
38 | ws.send("[echo] Hello there");
39 | expect(
40 | screen.getByText("(received) [echo] Hello there")
41 | ).toBeInTheDocument();
42 | });
43 | });
44 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/store/saga.js:
--------------------------------------------------------------------------------
1 | import { eventChannel, END } from "redux-saga";
2 | import { cancel, call, delay, fork, put, take } from "redux-saga/effects";
3 | import { actions } from "./reducer";
4 |
5 | const RECONNECT_TIMEOUT = 6000;
6 |
7 | function websocketInitChannel(connection) {
8 | return eventChannel(emitter => {
9 | const closeCallback = () => {
10 | emitter(actions.connectionLost());
11 | return emitter(END);
12 | };
13 |
14 | connection.onmessage = e => {
15 | return emitter(actions.storeReceivedMessage(e.data));
16 | };
17 |
18 | connection.onclose = closeCallback;
19 | connection.onerror = closeCallback;
20 |
21 | return () => {
22 | // unsubscribe function
23 | connection.close();
24 | };
25 | });
26 | }
27 |
28 | function* sendMessage(connection) {
29 | while (true) {
30 | const { payload } = yield take(actions.send);
31 | yield put(actions.storeSentMessage(payload));
32 | yield call([connection, connection.send], payload);
33 | }
34 | }
35 |
36 | export default function* saga() {
37 | const connection = new WebSocket(`ws://${window.location.hostname}:8080`);
38 | const channel = yield call(websocketInitChannel, connection);
39 | yield put(actions.connectionSuccess());
40 | const sendMessageTask = yield fork(sendMessage, connection);
41 | try {
42 | while (true) {
43 | const action = yield take(channel);
44 | yield put(action);
45 | }
46 | } finally {
47 | // cancel background tasks...
48 | channel.close();
49 | yield cancel(sendMessageTask);
50 | // ...and start again
51 | yield delay(RECONNECT_TIMEOUT);
52 | return yield call(saga);
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | push:
4 | branches:
5 | - "+([0-9])?(.{+([0-9]),x}).x"
6 | - "main"
7 | - "master"
8 | - "ci"
9 | - "test"
10 | - "next"
11 | - "next-major"
12 | - "beta"
13 | - "alpha"
14 | - "!all-contributors/**"
15 | pull_request: {}
16 | jobs:
17 | build:
18 | runs-on: ubuntu-latest
19 | strategy:
20 | matrix:
21 | node: ["14", "16", "17"]
22 | name: Build & test (Node ${{ matrix.node }})
23 | steps:
24 | - uses: actions/checkout@v2
25 | - name: Setup node
26 | uses: actions/setup-node@v2
27 | with:
28 | node-version: ${{ matrix.node }}
29 |
30 | # Install & build & test:
31 | - run: npm install
32 | - run: npm test
33 | - run: npm run type:check
34 | - run: npm run prettier:check
35 | - run: npm run build
36 | - run: npm pack
37 | # Run the tests in the examples folder
38 | # redux-saga:
39 | - run: |
40 | cd examples/redux-saga
41 | npm install
42 | npm install ../../jest-websocket-mock-*
43 | npm test -- --coverage
44 | # hooks:
45 | - run: |
46 | cd examples/hooks
47 | npm install
48 | npm install ../../jest-websocket-mock-*
49 | npm test -- --coverage
50 |
51 | coverage:
52 | needs: build
53 | runs-on: ubuntu-latest
54 | steps:
55 | - uses: actions/checkout@v2
56 | - name: Setup node
57 | uses: actions/setup-node@v2
58 | with:
59 | node-version: 16
60 | - run: npm install
61 | - run: npm install -g codecov
62 | - run: npm test -- --coverage
63 | - run: codecov
64 |
--------------------------------------------------------------------------------
/examples/redux-saga/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
22 | React App
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/examples/hooks/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jest-websocket-mock",
3 | "version": "2.4.0",
4 | "description": "Mock websockets and assert complex websocket interactions with Jest",
5 | "main": "lib/jest-websocket-mock.cjs.js",
6 | "module": "lib/jest-websocket-mock.es.js",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/romgain/jest-websocket-mock.git"
10 | },
11 | "types": "lib/index.d.ts",
12 | "scripts": {
13 | "clean": "rimraf lib",
14 | "build": "npm run clean && npm run build:lib && npm run build:types",
15 | "build:lib": "rollup -c",
16 | "build:types": "tsc -p tsconfig.build.json",
17 | "prettier:check": "prettier --list-different \"src/**/*.{ts,js,md}\" \"**/*.md\"",
18 | "prettier:apply": "prettier --write \"src/**/*.{ts,js}\" \"**/*.md\"",
19 | "type:check": "tsc --noEmit",
20 | "prepublishOnly": "npm run build",
21 | "test": "jest --colors"
22 | },
23 | "keywords": [
24 | "jest",
25 | "websocket",
26 | "mock",
27 | "unit-testing"
28 | ],
29 | "author": "Romain Bertrand",
30 | "license": "MIT",
31 | "jest": {
32 | "roots": [
33 | "/src"
34 | ],
35 | "coverageThreshold": {
36 | "global": {
37 | "branches": 100,
38 | "functions": 100,
39 | "lines": 100,
40 | "statements": 100
41 | }
42 | }
43 | },
44 | "devDependencies": {
45 | "@babel/core": "^7.4.0",
46 | "@babel/plugin-proposal-class-properties": "^7.4.0",
47 | "@babel/plugin-transform-runtime": "^7.4.0",
48 | "@babel/plugin-transform-typescript": "^7.4.0",
49 | "@babel/preset-env": "^7.4.2",
50 | "@babel/preset-typescript": "^7.3.3",
51 | "@babel/runtime": "^7.4.2",
52 | "@types/jest": "^28.1.6",
53 | "babel-jest": "^28.0.3",
54 | "jest": "^28.0.3",
55 | "prettier": "^2.0.2",
56 | "rimraf": "^3.0.0",
57 | "rollup": "^2.0.3",
58 | "rollup-plugin-babel": "^4.0.3",
59 | "rollup-plugin-node-resolve": "^5.2.0",
60 | "typescript": "^4.0.2"
61 | },
62 | "dependencies": {
63 | "jest-diff": "^28.0.2",
64 | "mock-socket": "^9.1.0"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to jest-websocket-mock
2 |
3 | ## Set up the workspace
4 |
5 | Fork the project, clone your fork, configure the remotes and install the dependencies:
6 |
7 | First, you'll need to [Fork the project, clone your fork and configure the remote](https://guides.github.com/activities/forking/).
8 |
9 | ```bash
10 | # Install the dependencies
11 | npm install
12 | # Navigate to the examples folder to set up the environment for working on the examples
13 | cd examples
14 | npm install
15 | cd ..
16 | ```
17 |
18 | ## Working with the code
19 |
20 | ### Prettier
21 |
22 | This codebase is formatted using [prettier](https://prettier.io/).
23 |
24 | - To check that the code is correctly formatted, run `npm run prettier:check`.
25 | - To automatically reformat the code with prettier, run `npm run prettier:apply`.
26 |
27 | ### TypeScript
28 |
29 | This codebase is written in [TypeScript](https://www.typescriptlang.org/).
30 |
31 | - To type-check the source tree, run `npm run type:check`.
32 |
33 | ### Tests
34 |
35 | To ensure consistency and quality, we enforce 100% test coverage, both for the `jest-websocket-mock` source code and for the [examples](https://github.com/romgain/jest-websocket-mock/blob/master/examples/src).
36 |
37 | - To run the tests ,run `npm test -- --coverage`.
38 | - To run the examples tests, run `SKIP_PREFLIGHT_CHECK=true npm test -- --coverage` in the `examples` folder. The `SKIP_PREFLIGHT_CHECK=true` environment variable is needed because Create React App detects a different jest version in the root folder (even if it doesn't use it).
39 |
40 | ### Testing the example app against a local build of the library
41 |
42 | To ensure that a new library build stays backwards compatible,
43 | it is useful to run the tests for the example app using a local library build.
44 | To do so:
45 |
46 | ```bash
47 | # build the library
48 | npm run build
49 | # generate a local library package
50 | npm pack
51 | # navigate to the examples folder
52 | cd examples
53 | # install the local library package
54 | npm install ../jest-websocket-mock-*
55 | # run the examples test suite
56 | SKIP_PREFLIGHT_CHECK=true npm test -- --coverage
57 | cd ..
58 | ```
59 |
--------------------------------------------------------------------------------
/examples/hooks/src/App.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | useState,
3 | ChangeEvent,
4 | FormEvent,
5 | useEffect,
6 | useRef,
7 | } from "react";
8 |
9 | type MessageProps = { text: string; side: "sent" | "received" };
10 | const Message = ({ text, side }: MessageProps) => (
11 | {`(${side}) ${text}`}
12 | );
13 |
14 | function App() {
15 | const wsRef = useRef();
16 | const [connected, setConnected] = useState(false);
17 | const [messages, setMessages] = useState([]);
18 | const [currentMessage, setCurrentMessage] = useState("");
19 |
20 | useEffect(() => {
21 | const ws = new WebSocket(`ws://${window.location.hostname}:8080`);
22 | ws.onopen = () => {
23 | setConnected(true);
24 | };
25 | ws.onclose = () => setConnected(false);
26 | ws.onmessage = (event) =>
27 | setMessages((m) => [{ side: "received", text: event.data }, ...m]);
28 | wsRef.current = ws;
29 | }, []);
30 |
31 | const onChange = (event: ChangeEvent) =>
32 | setCurrentMessage(event.target.value);
33 | const send = (event: FormEvent) => {
34 | event.preventDefault();
35 | wsRef.current!.send(currentMessage);
36 | setCurrentMessage("");
37 | setMessages((m) => [{ side: "sent", text: currentMessage }, ...m]);
38 | };
39 |
40 | return (
41 |
42 |
50 |
51 |
52 | {messages.map((message, i) => (
53 |
54 | ))}
55 |
56 |
57 |
66 |
67 | );
68 | }
69 |
70 | export default App;
71 |
--------------------------------------------------------------------------------
/examples/hooks/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `npm start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `npm test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `npm run build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `npm run eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
--------------------------------------------------------------------------------
/examples/redux-saga/src/__tests__/saga.test.js:
--------------------------------------------------------------------------------
1 | import WS from "jest-websocket-mock";
2 | import makeStore from "../store";
3 | import { actions } from "../store/reducer";
4 |
5 | let ws, store;
6 | beforeEach(async () => {
7 | ws = new WS("ws://localhost:8080");
8 | store = makeStore();
9 | await ws.connected;
10 | ws.send("Hello there");
11 | });
12 | afterEach(() => {
13 | WS.clean();
14 | });
15 |
16 | describe("The saga", () => {
17 | it("connects to the websocket server", () => {
18 | expect(store.getState().messages).toEqual([
19 | { side: "received", text: "Hello there" },
20 | ]);
21 | });
22 |
23 | it("stores new messages", () => {
24 | ws.send("how you doin?");
25 | expect(store.getState().messages).toEqual([
26 | { side: "received", text: "Hello there" },
27 | { side: "received", text: "how you doin?" },
28 | ]);
29 | });
30 |
31 | it("stores new messages received shortly one after the other", () => {
32 | ws.send("hey");
33 | ws.send("hey?");
34 | ws.send("hey??");
35 | ws.send("hey???");
36 | expect(store.getState().messages).toEqual([
37 | { side: "received", text: "Hello there" },
38 | { side: "received", text: "hey" },
39 | { side: "received", text: "hey?" },
40 | { side: "received", text: "hey??" },
41 | { side: "received", text: "hey???" },
42 | ]);
43 | });
44 |
45 | it("sends messages", async () => {
46 | store.dispatch(actions.send("oh hi Mark"));
47 | await expect(ws).toReceiveMessage("oh hi Mark");
48 |
49 | expect(ws).toHaveReceivedMessages(["oh hi Mark"]);
50 | expect(store.getState().messages).toEqual([
51 | { side: "received", text: "Hello there" },
52 | { side: "sent", text: "oh hi Mark" },
53 | ]);
54 | });
55 |
56 | it("sends messages in a quick succession", async () => {
57 | store.dispatch(actions.send("hey"));
58 | store.dispatch(actions.send("hey?"));
59 | store.dispatch(actions.send("hey??"));
60 | store.dispatch(actions.send("hey???"));
61 | await expect(ws).toReceiveMessage("hey");
62 | await expect(ws).toReceiveMessage("hey?");
63 | await expect(ws).toReceiveMessage("hey??");
64 | await expect(ws).toReceiveMessage("hey???");
65 |
66 | expect(ws).toHaveReceivedMessages(["hey", "hey?", "hey??", "hey???"]);
67 | expect(store.getState().messages).toEqual([
68 | { side: "received", text: "Hello there" },
69 | { side: "sent", text: "hey" },
70 | { side: "sent", text: "hey?" },
71 | { side: "sent", text: "hey??" },
72 | { side: "sent", text: "hey???" },
73 | ]);
74 | });
75 |
76 | it("marks the connection as active when it successfully connects to the ws server", () => {
77 | expect(store.getState().connected).toBe(true);
78 | });
79 |
80 | it("marks the connection as inactive after a disconnect", async () => {
81 | ws.close();
82 | await ws.closed;
83 | expect(store.getState().connected).toBe(false);
84 | });
85 |
86 | it("marks the connection as inactive after a connection error", async () => {
87 | ws.error();
88 | await ws.closed;
89 | expect(store.getState().connected).toBe(false);
90 | });
91 |
92 | it("reconnects after losing the ws connection", async () => {
93 | // We cannot use jest.useFakeTimers because mock-socket has to work around timing issues
94 | jest.spyOn(window, "setTimeout");
95 |
96 | ws.error();
97 | await ws.closed;
98 | expect(store.getState().connected).toBe(false);
99 |
100 | // Trigger our delayed reconnection
101 | window.setTimeout.mock.calls.forEach(([cb, , ...args]) => cb(...args));
102 |
103 | await ws.connected; // reconnected!
104 | expect(store.getState().connected).toBe(true);
105 |
106 | window.setTimeout.mockRestore();
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/src/websocket.ts:
--------------------------------------------------------------------------------
1 | import { Server, ServerOptions, CloseOptions, Client } from "mock-socket";
2 | import Queue from "./queue";
3 | import act from "./act-compat";
4 |
5 | const identity = (x: string) => x;
6 |
7 | interface WSOptions extends ServerOptions {
8 | jsonProtocol?: boolean;
9 | }
10 | export type DeserializedMessage = string | TMessage;
11 |
12 | // The WebSocket object passed to the `connection` callback is actually
13 | // a WebSocket proxy that overrides the signature of the `close` method.
14 | // To work around this inconsistency, we need to override the WebSocket
15 | // interface. See https://github.com/romgain/jest-websocket-mock/issues/26#issuecomment-571579567
16 | interface MockWebSocket extends Omit {
17 | close(options?: CloseOptions): void;
18 | }
19 |
20 | export default class WS {
21 | server: Server;
22 | serializer: (deserializedMessage: DeserializedMessage) => string;
23 | deserializer: (message: string) => DeserializedMessage;
24 |
25 | static instances: Array = [];
26 | messages: Array = [];
27 | messagesToConsume = new Queue();
28 |
29 | private _isConnected: Promise;
30 | private _isClosed: Promise<{}>;
31 |
32 | static clean() {
33 | WS.instances.forEach((instance) => {
34 | instance.close();
35 | instance.messages = [];
36 | });
37 | WS.instances = [];
38 | }
39 |
40 | constructor(url: string, opts: WSOptions = {}) {
41 | WS.instances.push(this);
42 |
43 | const { jsonProtocol = false, ...serverOptions } = opts;
44 | this.serializer = jsonProtocol ? JSON.stringify : identity;
45 | this.deserializer = jsonProtocol ? JSON.parse : identity;
46 |
47 | let connectionResolver: (socket: Client) => void,
48 | closedResolver!: (socket: Client) => void;
49 | this._isConnected = new Promise((done) => (connectionResolver = done));
50 | this._isClosed = new Promise((done) => (closedResolver = done));
51 |
52 | this.server = new Server(url, serverOptions);
53 |
54 | this.server.on("close", closedResolver);
55 |
56 | this.server.on("connection", (socket: Client) => {
57 | connectionResolver(socket);
58 |
59 | socket.on("message", (message) => {
60 | const parsedMessage = this.deserializer(message as string);
61 | this.messages.push(parsedMessage);
62 | this.messagesToConsume.put(parsedMessage);
63 | });
64 | });
65 | }
66 |
67 | get connected() {
68 | let resolve: (socket: Client) => void;
69 | const connectedPromise = new Promise((done) => (resolve = done));
70 | const waitForConnected = async () => {
71 | await act(async () => {
72 | await this._isConnected;
73 | });
74 | resolve(await this._isConnected); // make sure `await act` is really done
75 | };
76 | waitForConnected();
77 | return connectedPromise;
78 | }
79 |
80 | get closed() {
81 | let resolve: () => void;
82 | const closedPromise = new Promise((done) => (resolve = done));
83 | const waitForclosed = async () => {
84 | await act(async () => {
85 | await this._isClosed;
86 | });
87 | await this._isClosed; // make sure `await act` is really done
88 | resolve();
89 | };
90 | waitForclosed();
91 | return closedPromise;
92 | }
93 |
94 | get nextMessage() {
95 | return this.messagesToConsume.get();
96 | }
97 |
98 | on(
99 | eventName: "connection" | "message" | "close",
100 | callback: (socket: MockWebSocket) => void
101 | ): void {
102 | // @ts-ignore https://github.com/romgain/jest-websocket-mock/issues/26#issuecomment-571579567
103 | this.server.on(eventName, callback);
104 | }
105 |
106 | send(message: DeserializedMessage) {
107 | act(() => {
108 | this.server.emit("message", this.serializer(message));
109 | });
110 | }
111 |
112 | close(options?: CloseOptions) {
113 | act(() => {
114 | this.server.close(options);
115 | });
116 | }
117 |
118 | error(options?: CloseOptions) {
119 | act(() => {
120 | this.server.emit("error", null);
121 | });
122 | this.server.close(options);
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/matchers.ts:
--------------------------------------------------------------------------------
1 | import { diff } from "jest-diff";
2 | import WS from "./websocket";
3 | import { DeserializedMessage } from "./websocket";
4 |
5 | type ReceiveMessageOptions = {
6 | timeout?: number;
7 | };
8 |
9 | declare global {
10 | namespace jest {
11 | interface Matchers {
12 | toReceiveMessage(
13 | message: DeserializedMessage,
14 | options?: ReceiveMessageOptions
15 | ): Promise;
16 | toHaveReceivedMessages(
17 | messages: Array>
18 | ): R;
19 | }
20 | }
21 | }
22 |
23 | const WAIT_DELAY = 1000;
24 | const TIMEOUT = Symbol("timoeut");
25 |
26 | const makeInvalidWsMessage = function makeInvalidWsMessage(
27 | this: jest.MatcherUtils,
28 | ws: WS,
29 | matcher: string
30 | ) {
31 | return (
32 | this.utils.matcherHint(
33 | this.isNot ? `.not.${matcher}` : `.${matcher}`,
34 | "WS",
35 | "expected"
36 | ) +
37 | "\n\n" +
38 | `Expected the websocket object to be a valid WS mock.\n` +
39 | `Received: ${typeof ws}\n` +
40 | ` ${this.utils.printReceived(ws)}`
41 | );
42 | };
43 |
44 | expect.extend({
45 | async toReceiveMessage(
46 | ws: WS,
47 | expected: DeserializedMessage,
48 | options?: ReceiveMessageOptions
49 | ) {
50 | const isWS = ws instanceof WS;
51 | if (!isWS) {
52 | return {
53 | pass: this.isNot, // always fail
54 | message: makeInvalidWsMessage.bind(this, ws, "toReceiveMessage"),
55 | };
56 | }
57 |
58 | const waitDelay = options?.timeout ?? WAIT_DELAY;
59 |
60 | const messageOrTimeout = await Promise.race([
61 | ws.nextMessage,
62 | new Promise((resolve) => setTimeout(() => resolve(TIMEOUT), waitDelay)),
63 | ]);
64 |
65 | if (messageOrTimeout === TIMEOUT) {
66 | return {
67 | pass: this.isNot, // always fail
68 | message: () =>
69 | this.utils.matcherHint(
70 | this.isNot ? ".not.toReceiveMessage" : ".toReceiveMessage",
71 | "WS",
72 | "expected"
73 | ) +
74 | "\n\n" +
75 | `Expected the websocket server to receive a message,\n` +
76 | `but it didn't receive anything in ${waitDelay}ms.`,
77 | };
78 | }
79 | const received = messageOrTimeout;
80 |
81 | const pass = this.equals(received, expected);
82 |
83 | const message = pass
84 | ? () =>
85 | this.utils.matcherHint(".not.toReceiveMessage", "WS", "expected") +
86 | "\n\n" +
87 | `Expected the next received message to not equal:\n` +
88 | ` ${this.utils.printExpected(expected)}\n` +
89 | `Received:\n` +
90 | ` ${this.utils.printReceived(received)}`
91 | : () => {
92 | const diffString = diff(expected, received, { expand: this.expand });
93 | return (
94 | this.utils.matcherHint(".toReceiveMessage", "WS", "expected") +
95 | "\n\n" +
96 | `Expected the next received message to equal:\n` +
97 | ` ${this.utils.printExpected(expected)}\n` +
98 | `Received:\n` +
99 | ` ${this.utils.printReceived(received)}\n\n` +
100 | `Difference:\n\n${diffString}`
101 | );
102 | };
103 |
104 | return {
105 | actual: received,
106 | expected,
107 | message,
108 | name: "toReceiveMessage",
109 | pass,
110 | };
111 | },
112 |
113 | toHaveReceivedMessages(ws: WS, messages: Array) {
114 | const isWS = ws instanceof WS;
115 | if (!isWS) {
116 | return {
117 | pass: this.isNot, // always fail
118 | message: makeInvalidWsMessage.bind(this, ws, "toHaveReceivedMessages"),
119 | };
120 | }
121 |
122 | const received = messages.map((expected) =>
123 | // object comparison to handle JSON protocols
124 | ws.messages.some((actual) => this.equals(actual, expected))
125 | );
126 | const pass = this.isNot ? received.some(Boolean) : received.every(Boolean);
127 | const message = pass
128 | ? () =>
129 | this.utils.matcherHint(
130 | ".not.toHaveReceivedMessages",
131 | "WS",
132 | "expected"
133 | ) +
134 | "\n\n" +
135 | `Expected the WS server to not have received the following messages:\n` +
136 | ` ${this.utils.printExpected(messages)}\n` +
137 | `But it received:\n` +
138 | ` ${this.utils.printReceived(ws.messages)}`
139 | : () => {
140 | return (
141 | this.utils.matcherHint(
142 | ".toHaveReceivedMessages",
143 | "WS",
144 | "expected"
145 | ) +
146 | "\n\n" +
147 | `Expected the WS server to have received the following messages:\n` +
148 | ` ${this.utils.printExpected(messages)}\n` +
149 | `Received:\n` +
150 | ` ${this.utils.printReceived(ws.messages)}\n\n`
151 | );
152 | };
153 |
154 | return {
155 | actual: ws.messages,
156 | expected: messages,
157 | message,
158 | name: "toHaveReceivedMessages",
159 | pass,
160 | };
161 | },
162 | });
163 |
--------------------------------------------------------------------------------
/src/__tests__/matchers.test.ts:
--------------------------------------------------------------------------------
1 | import WS from "../websocket";
2 | import "../matchers";
3 |
4 | let server: WS, client: WebSocket;
5 | beforeEach(async () => {
6 | server = new WS("ws://localhost:1234");
7 | client = new WebSocket("ws://localhost:1234");
8 | await server.connected;
9 | });
10 |
11 | afterEach(() => {
12 | WS.clean();
13 | });
14 |
15 | describe(".toReceiveMessage", () => {
16 | it("passes when the websocket server receives the expected message", async () => {
17 | client.send("hello there");
18 | await expect(server).toReceiveMessage("hello there");
19 | });
20 |
21 | it("passes when the websocket server receives the expected message with custom timeout", async () => {
22 | setTimeout(() => {
23 | client.send("hello there");
24 | }, 2000);
25 |
26 | await expect(server).toReceiveMessage("hello there", { timeout: 3000 });
27 | });
28 |
29 | it("passes when the websocket server receives the expected JSON message", async () => {
30 | const jsonServer = new WS("ws://localhost:9876", { jsonProtocol: true });
31 | const jsonClient = new WebSocket("ws://localhost:9876");
32 | await jsonServer.connected;
33 | jsonClient.send(`{"answer":42}`);
34 | await expect(jsonServer).toReceiveMessage({ answer: 42 });
35 | });
36 |
37 | it("fails when called with an expected argument that is not a valid WS", async () => {
38 | expect.hasAssertions();
39 | await expect(expect("boom").toReceiveMessage("hello there")).rejects
40 | .toThrowErrorMatchingInlineSnapshot(`
41 | "[2mexpect([22m[31mWS[39m[2m).toReceiveMessage([22m[32mexpected[39m[2m)[22m
42 |
43 | Expected the websocket object to be a valid WS mock.
44 | Received: string
45 | [31m\\"boom\\"[39m"
46 | `);
47 | });
48 |
49 | it("fails when the WS server does not receive the expected message", async () => {
50 | expect.hasAssertions();
51 | await expect(expect(server).toReceiveMessage("hello there")).rejects
52 | .toThrowErrorMatchingInlineSnapshot(`
53 | "[2mexpect([22m[31mWS[39m[2m).toReceiveMessage([22m[32mexpected[39m[2m)[22m
54 |
55 | Expected the websocket server to receive a message,
56 | but it didn't receive anything in 1000ms."
57 | `);
58 | });
59 |
60 | it("fails when the WS server does not receive the expected message with custom timeout", async () => {
61 | expect.hasAssertions();
62 | await expect(
63 | expect(server).toReceiveMessage("hello there", { timeout: 3000 })
64 | ).rejects.toThrowErrorMatchingInlineSnapshot(`
65 | "[2mexpect([22m[31mWS[39m[2m).toReceiveMessage([22m[32mexpected[39m[2m)[22m
66 |
67 | Expected the websocket server to receive a message,
68 | but it didn't receive anything in 3000ms."
69 | `);
70 | });
71 |
72 | it("fails when the WS server receives a different message", async () => {
73 | expect.hasAssertions();
74 | client.send("hello there");
75 | await expect(expect(server).toReceiveMessage("HI!")).rejects
76 | .toThrowErrorMatchingInlineSnapshot(`
77 | "[2mexpect([22m[31mWS[39m[2m).toReceiveMessage([22m[32mexpected[39m[2m)[22m
78 |
79 | Expected the next received message to equal:
80 | [32m\\"HI!\\"[39m
81 | Received:
82 | [31m\\"hello there\\"[39m
83 |
84 | Difference:
85 |
86 | [32m- Expected[39m
87 | [31m+ Received[39m
88 |
89 | [32m- HI![39m
90 | [31m+ hello there[39m"
91 | `);
92 | });
93 |
94 | it("fails when expecting a JSON message but the server is not configured for JSON protocols", async () => {
95 | expect.hasAssertions();
96 | client.send(`{"answer":42}`);
97 | await expect(expect(server).toReceiveMessage({ answer: 42 })).rejects
98 | .toThrowErrorMatchingInlineSnapshot(`
99 | "[2mexpect([22m[31mWS[39m[2m).toReceiveMessage([22m[32mexpected[39m[2m)[22m
100 |
101 | Expected the next received message to equal:
102 | [32m{\\"answer\\": 42}[39m
103 | Received:
104 | [31m\\"{\\\\\\"answer\\\\\\":42}\\"[39m
105 |
106 | Difference:
107 |
108 | Comparing two different types of values. Expected [32mobject[39m but received [31mstring[39m."
109 | `);
110 | });
111 | });
112 |
113 | describe(".not.toReceiveMessage", () => {
114 | it("passes when the websocket server doesn't receive the expected message", async () => {
115 | client.send("hello there");
116 | await expect(server).not.toReceiveMessage("What's up?");
117 | });
118 |
119 | it("fails when called with an expected argument that is not a valid WS", async () => {
120 | expect.hasAssertions();
121 | await expect(expect("boom").not.toReceiveMessage("hello there")).rejects
122 | .toThrowErrorMatchingInlineSnapshot(`
123 | "[2mexpect([22m[31mWS[39m[2m).not.toReceiveMessage([22m[32mexpected[39m[2m)[22m
124 |
125 | Expected the websocket object to be a valid WS mock.
126 | Received: string
127 | [31m\\"boom\\"[39m"
128 | `);
129 | });
130 |
131 | it("fails when the WS server doesn't receive any messages", async () => {
132 | expect.hasAssertions();
133 | await expect(expect(server).not.toReceiveMessage("hello there")).rejects
134 | .toThrowErrorMatchingInlineSnapshot(`
135 | "[2mexpect([22m[31mWS[39m[2m).not.toReceiveMessage([22m[32mexpected[39m[2m)[22m
136 |
137 | Expected the websocket server to receive a message,
138 | but it didn't receive anything in 1000ms."
139 | `);
140 | });
141 |
142 | it("fails when the WS server receives the un-expected message", async () => {
143 | expect.hasAssertions();
144 | client.send("hello there");
145 | await expect(expect(server).not.toReceiveMessage("hello there")).rejects
146 | .toThrowErrorMatchingInlineSnapshot(`
147 | "[2mexpect([22m[31mWS[39m[2m).not.toReceiveMessage([22m[32mexpected[39m[2m)[22m
148 |
149 | Expected the next received message to not equal:
150 | [32m\\"hello there\\"[39m
151 | Received:
152 | [31m\\"hello there\\"[39m"
153 | `);
154 | });
155 | });
156 |
157 | describe(".toHaveReceivedMessages", () => {
158 | it("passes when the websocket server received the expected messages", async () => {
159 | client.send("hello there");
160 | client.send("how are you?");
161 | client.send("good?");
162 | await server.nextMessage;
163 | await server.nextMessage;
164 | await server.nextMessage;
165 | expect(server).toHaveReceivedMessages(["hello there", "good?"]);
166 | });
167 |
168 | it("passes when the websocket server received the expected JSON messages", async () => {
169 | const jsonServer = new WS("ws://localhost:9876", { jsonProtocol: true });
170 | const jsonClient = new WebSocket("ws://localhost:9876");
171 | await jsonServer.connected;
172 | jsonClient.send(`{"type":"GREETING","payload":"hello there"}`);
173 | jsonClient.send(`{"type":"GREETING","payload":"how are you?"}`);
174 | jsonClient.send(`{"type":"GREETING","payload":"good?"}`);
175 | await jsonServer.nextMessage;
176 | await jsonServer.nextMessage;
177 | await jsonServer.nextMessage;
178 | expect(jsonServer).toHaveReceivedMessages([
179 | { type: "GREETING", payload: "good?" },
180 | { type: "GREETING", payload: "hello there" },
181 | ]);
182 | });
183 |
184 | it("fails when the websocket server did not receive the expected messages", async () => {
185 | client.send("hello there");
186 | client.send("how are you?");
187 | client.send("good?");
188 | await server.nextMessage;
189 | await server.nextMessage;
190 | await server.nextMessage;
191 | expect(() => {
192 | expect(server).toHaveReceivedMessages(["hello there", "'sup?"]);
193 | }).toThrowErrorMatchingInlineSnapshot(`
194 | "[2mexpect([22m[31mWS[39m[2m).toHaveReceivedMessages([22m[32mexpected[39m[2m)[22m
195 |
196 | Expected the WS server to have received the following messages:
197 | [32m[\\"hello there\\", \\"'sup?\\"][39m
198 | Received:
199 | [31m[\\"hello there\\", \\"how are you?\\", \\"good?\\"][39m
200 |
201 | "
202 | `);
203 | });
204 |
205 | it("fails when called with an expected argument that is not a valid WS", async () => {
206 | expect(() => {
207 | expect("boom").toHaveReceivedMessages(["hello there"]);
208 | }).toThrowErrorMatchingInlineSnapshot(`
209 | "[2mexpect([22m[31mWS[39m[2m).toHaveReceivedMessages([22m[32mexpected[39m[2m)[22m
210 |
211 | Expected the websocket object to be a valid WS mock.
212 | Received: string
213 | [31m\\"boom\\"[39m"
214 | `);
215 | });
216 | });
217 |
218 | describe(".not.toHaveReceivedMessages", () => {
219 | it("passes when the websocket server received none of the specified messages", async () => {
220 | client.send("hello there");
221 | client.send("how are you?");
222 | client.send("good?");
223 | await server.nextMessage;
224 | await server.nextMessage;
225 | await server.nextMessage;
226 | expect(server).not.toHaveReceivedMessages(["'sup?", "U good?"]);
227 | });
228 |
229 | it("fails when the websocket server received at least one unexpected message", async () => {
230 | client.send("hello there");
231 | client.send("how are you?");
232 | client.send("good?");
233 | await server.nextMessage;
234 | await server.nextMessage;
235 | await server.nextMessage;
236 | expect(() => {
237 | expect(server).not.toHaveReceivedMessages([
238 | "'sup?",
239 | "U good?",
240 | "hello there",
241 | ]);
242 | }).toThrowErrorMatchingInlineSnapshot(`
243 | "[2mexpect([22m[31mWS[39m[2m).not.toHaveReceivedMessages([22m[32mexpected[39m[2m)[22m
244 |
245 | Expected the WS server to not have received the following messages:
246 | [32m[\\"'sup?\\", \\"U good?\\", \\"hello there\\"][39m
247 | But it received:
248 | [31m[\\"hello there\\", \\"how are you?\\", \\"good?\\"][39m"
249 | `);
250 | });
251 |
252 | it("fails when called with an expected argument that is not a valid WS", async () => {
253 | expect(() => {
254 | expect("boom").not.toHaveReceivedMessages(["hello there"]);
255 | }).toThrowErrorMatchingInlineSnapshot(`
256 | "[2mexpect([22m[31mWS[39m[2m).not.toHaveReceivedMessages([22m[32mexpected[39m[2m)[22m
257 |
258 | Expected the websocket object to be a valid WS mock.
259 | Received: string
260 | [31m\\"boom\\"[39m"
261 | `);
262 | });
263 | });
264 |
--------------------------------------------------------------------------------
/src/__tests__/websocket.test.ts:
--------------------------------------------------------------------------------
1 | import WS from "../websocket";
2 |
3 | describe("The WS helper", () => {
4 | afterEach(() => {
5 | WS.clean();
6 | });
7 |
8 | it("keeps track of received messages, and yields them as they come in", async () => {
9 | const server = new WS("ws://localhost:1234");
10 | const client = new WebSocket("ws://localhost:1234");
11 |
12 | await server.connected;
13 | client.send("hello");
14 | const message = await server.nextMessage;
15 | expect(message).toBe("hello");
16 | expect(server.messages).toEqual(["hello"]);
17 | });
18 |
19 | it("cleans up connected clients and messages on 'clean'", async () => {
20 | const server = new WS("ws://localhost:1234");
21 | const client1 = new WebSocket("ws://localhost:1234");
22 | await server.connected;
23 | const client2 = new WebSocket("ws://localhost:1234");
24 | await server.connected;
25 |
26 | const connections = { client1: true, client2: true };
27 | const onclose = (name: "client1" | "client2") => () => {
28 | connections[name] = false;
29 | };
30 | client1.onclose = onclose("client1");
31 | client2.onclose = onclose("client2");
32 |
33 | client1.send("hello 1");
34 | await server.nextMessage;
35 | client2.send("hello 2");
36 | await server.nextMessage;
37 | expect(server.messages).toEqual(["hello 1", "hello 2"]);
38 |
39 | WS.clean();
40 | expect(WS.instances).toEqual([]);
41 | expect(server.messages).toEqual([]);
42 | expect(connections).toEqual({ client1: false, client2: false });
43 | });
44 |
45 | it("handles messages received in a quick succession", async () => {
46 | expect.hasAssertions();
47 | const server = new WS("ws://localhost:1234");
48 | const client = new WebSocket("ws://localhost:1234");
49 | await server.connected;
50 |
51 | "abcdef".split("").forEach(client.send.bind(client));
52 |
53 | let waitedEnough: (value: void) => void;
54 | const waitABit = new Promise((done) => (waitedEnough = done));
55 |
56 | setTimeout(async () => {
57 | await server.nextMessage;
58 | await server.nextMessage;
59 | await server.nextMessage;
60 | await server.nextMessage;
61 | await server.nextMessage;
62 | await server.nextMessage;
63 |
64 | "xyz".split("").forEach(client.send.bind(client));
65 | await server.nextMessage;
66 | await server.nextMessage;
67 | await server.nextMessage;
68 | waitedEnough();
69 | }, 500);
70 |
71 | await waitABit;
72 | expect(server.messages).toEqual("abcdefxyz".split(""));
73 | });
74 |
75 | it("sends messages to connected clients", async () => {
76 | const server = new WS("ws://localhost:1234");
77 | const client1 = new WebSocket("ws://localhost:1234");
78 | await server.connected;
79 | const client2 = new WebSocket("ws://localhost:1234");
80 | await server.connected;
81 |
82 | interface Messages {
83 | client1: Array;
84 | client2: Array;
85 | }
86 | const messages: Messages = { client1: [], client2: [] };
87 | client1.onmessage = (e) => {
88 | messages.client1.push(e.data);
89 | };
90 | client2.onmessage = (e) => {
91 | messages.client2.push(e.data);
92 | };
93 |
94 | server.send("hello everyone");
95 | expect(messages).toEqual({
96 | client1: ["hello everyone"],
97 | client2: ["hello everyone"],
98 | });
99 | });
100 |
101 | it("seamlessly handles JSON protocols", async () => {
102 | const server = new WS("ws://localhost:1234", { jsonProtocol: true });
103 | const client = new WebSocket("ws://localhost:1234");
104 |
105 | await server.connected;
106 | client.send(`{ "type": "GREETING", "payload": "hello" }`);
107 | const received = await server.nextMessage;
108 | expect(server.messages).toEqual([{ type: "GREETING", payload: "hello" }]);
109 | expect(received).toEqual({ type: "GREETING", payload: "hello" });
110 |
111 | let message = null;
112 | client.onmessage = (e) => {
113 | message = e.data;
114 | };
115 |
116 | server.send({ type: "CHITCHAT", payload: "Nice weather today" });
117 | expect(message).toEqual(
118 | `{"type":"CHITCHAT","payload":"Nice weather today"}`
119 | );
120 | });
121 |
122 | it("rejects connections that fail the verifyClient option", async () => {
123 | const verifyClient = jest.fn().mockReturnValue(false);
124 | new WS("ws://localhost:1234", { verifyClient: verifyClient });
125 | const errorCallback = jest.fn();
126 |
127 | await expect(
128 | new Promise((resolve, reject) => {
129 | errorCallback.mockImplementation(reject);
130 | const client = new WebSocket("ws://localhost:1234");
131 | client.onerror = errorCallback;
132 | client.onopen = resolve;
133 | })
134 | // WebSocket onerror event gets called with an event of type error and not an error
135 | ).rejects.toEqual(expect.objectContaining({ type: "error" }));
136 |
137 | expect(verifyClient).toHaveBeenCalledTimes(1);
138 | expect(errorCallback).toHaveBeenCalledTimes(1);
139 |
140 | // ensure that the WebSocket mock set up by mock-socket is still present
141 | expect(WebSocket).toBeDefined();
142 | });
143 |
144 | it("rejects connections that fail the selectProtocol option", async () => {
145 | const selectProtocol = () => null;
146 | new WS("ws://localhost:1234", { selectProtocol });
147 | const errorCallback = jest.fn();
148 |
149 | await expect(
150 | new Promise((resolve, reject) => {
151 | errorCallback.mockImplementationOnce(reject);
152 | const client = new WebSocket("ws://localhost:1234", "foo");
153 | client.onerror = errorCallback;
154 | client.onopen = resolve;
155 | })
156 | ).rejects.toEqual(
157 | // WebSocket onerror event gets called with an event of type error and not an error
158 | expect.objectContaining({
159 | type: "error",
160 | currentTarget: expect.objectContaining({ protocol: "foo" }),
161 | })
162 | );
163 |
164 | // ensure that the WebSocket mock set up by mock-socket is still present
165 | expect(WebSocket).toBeDefined();
166 | });
167 |
168 | it("closes the connection", async () => {
169 | const server = new WS("ws://localhost:1234");
170 | const client = new WebSocket("ws://localhost:1234");
171 | const closeCallback = jest.fn();
172 | await server.connected;
173 |
174 | client.onclose = closeCallback;
175 |
176 | server.send("hello everyone");
177 | server.close();
178 |
179 | expect(closeCallback).toHaveBeenCalledTimes(1);
180 | expect(closeCallback).toHaveBeenCalledWith(
181 | expect.objectContaining({
182 | code: 1000,
183 | eventPhase: 0,
184 | reason: "",
185 | type: "close",
186 | wasClean: true,
187 | })
188 | );
189 |
190 | // ensure that the WebSocket mock set up by mock-socket is still present
191 | expect(WebSocket).toBeDefined();
192 | });
193 |
194 | it("closes the connection with a custom close code", async () => {
195 | const server = new WS("ws://localhost:1234");
196 | const client = new WebSocket("ws://localhost:1234");
197 | const closeCallback = jest.fn();
198 | await server.connected;
199 | client.onclose = closeCallback;
200 |
201 | server.close({ code: 1234, reason: "boom", wasClean: false });
202 |
203 | expect(closeCallback).toHaveBeenCalledTimes(1);
204 | expect(closeCallback).toHaveBeenCalledWith(
205 | expect.objectContaining({
206 | code: 1234,
207 | eventPhase: 0,
208 | reason: "boom",
209 | type: "close",
210 | wasClean: false,
211 | })
212 | );
213 | });
214 |
215 | it("can refuse connections", async () => {
216 | expect.assertions(6);
217 |
218 | const server = new WS("ws://localhost:1234");
219 | server.on("connection", (socket) => {
220 | socket.close({ wasClean: false, code: 1003, reason: "NOPE" });
221 | });
222 |
223 | const client = new WebSocket("ws://localhost:1234");
224 | client.onclose = (event: CloseEvent) => {
225 | expect(event.code).toBe(1003);
226 | expect(event.wasClean).toBe(false);
227 | expect(event.reason).toBe("NOPE");
228 | };
229 |
230 | expect(client.readyState).toBe(WebSocket.CONNECTING);
231 |
232 | await server.connected;
233 | expect(client.readyState).toBe(WebSocket.CLOSING);
234 |
235 | await server.closed;
236 | expect(client.readyState).toBe(WebSocket.CLOSED);
237 | });
238 |
239 | it("can send messages in the connection callback", async () => {
240 | expect.assertions(1);
241 |
242 | const server = new WS("ws://localhost:1234");
243 | let receivedMessage = null;
244 |
245 | server.on("connection", (socket) => {
246 | socket.send("hello there");
247 | });
248 |
249 | const client = new WebSocket("ws://localhost:1234");
250 | client.onmessage = (event) => {
251 | receivedMessage = event.data;
252 | };
253 |
254 | await server.connected;
255 | expect(receivedMessage).toBe("hello there");
256 | });
257 |
258 | it("provides a callback when receiving messages", async () => {
259 | const server = new WS("ws://localhost:1234");
260 | expect.assertions(1);
261 |
262 | server.on("connection", (socket) => {
263 | socket.on("message", (msg) => {
264 | expect(msg).toEqual("client says hi");
265 | });
266 | });
267 |
268 | const client = new WebSocket("ws://localhost:1234");
269 | await server.connected;
270 | client.send("client says hi");
271 | await server.nextMessage;
272 | });
273 |
274 | it("sends errors to connected clients", async () => {
275 | const server = new WS("ws://localhost:1234");
276 | const client = new WebSocket("ws://localhost:1234");
277 | await server.connected;
278 |
279 | let disconnected = false;
280 | let error: any; // bad types in MockSockets
281 | client.onclose = () => {
282 | disconnected = true;
283 | };
284 | client.onerror = (e) => {
285 | error = e;
286 | };
287 |
288 | server.send("hello everyone");
289 | server.error();
290 | expect(disconnected).toBe(true);
291 | expect(error.origin).toBe("ws://localhost:1234/");
292 | expect(error.type).toBe("error");
293 | });
294 |
295 | it("resolves the client socket that connected", async () => {
296 | const server = new WS("ws://localhost:1234");
297 | const client = new WebSocket("ws://localhost:1234");
298 |
299 | const socket = await server.connected;
300 |
301 | expect(socket).toStrictEqual(client);
302 | });
303 |
304 | it("passes on close options on server error event", async () => {
305 | const server = new WS("ws://localhost:1234");
306 | const client = new WebSocket("ws://localhost:1234");
307 | const closeCallback = jest.fn();
308 | await server.connected;
309 | client.onclose = closeCallback;
310 |
311 | server.error({ code: 1234, reason: "boom", wasClean: false });
312 |
313 | expect(closeCallback).toHaveBeenCalledTimes(1);
314 | expect(closeCallback).toHaveBeenCalledWith(
315 | expect.objectContaining({
316 | code: 1234,
317 | eventPhase: 0,
318 | reason: "boom",
319 | type: "close",
320 | wasClean: false,
321 | })
322 | );
323 | });
324 | });
325 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Jest websocket mock
2 |
3 | [](https://badge.fury.io/js/jest-websocket-mock)
4 | [](https://github.com/romgain/jest-websocket-mock/actions)
5 | [](https://codecov.io/gh/romgain/jest-websocket-mock)
6 | [](https://github.com/prettier/prettier)
7 |
8 | A set of utilities and Jest matchers to help testing complex websocket interactions.
9 |
10 | **Examples:**
11 | Several examples are provided in the [examples folder](https://github.com/romgain/jest-websocket-mock/blob/master/examples/).
12 | In particular:
13 |
14 | - [testing a redux saga that manages a websocket connection](https://github.com/romgain/jest-websocket-mock/blob/master/examples/redux-saga/src/__tests__/saga.test.js)
15 | - [testing a component using the saga above](https://github.com/romgain/jest-websocket-mock/blob/master/examples/redux-saga/src/__tests__/App.test.js)
16 | - [testing a component that manages a websocket connection using react hooks](https://github.com/romgain/jest-websocket-mock/blob/master/examples/hooks/src/App.test.tsx)
17 |
18 | ## Install
19 |
20 | ```bash
21 | npm install --save-dev jest-websocket-mock
22 | ```
23 |
24 | ## Mock a websocket server
25 |
26 | ### The `WS` constructor
27 |
28 | `jest-websocket-mock` exposes a `WS` class that can instantiate mock websocket
29 | servers that keep track of the messages they receive, and in turn
30 | can send messages to connected clients.
31 |
32 | ```js
33 | import WS from "jest-websocket-mock";
34 |
35 | // create a WS instance, listening on port 1234 on localhost
36 | const server = new WS("ws://localhost:1234");
37 |
38 | // real clients can connect
39 | const client = new WebSocket("ws://localhost:1234");
40 | await server.connected; // wait for the server to have established the connection
41 |
42 | // the mock websocket server will record all the messages it receives
43 | client.send("hello");
44 |
45 | // the mock websocket server can also send messages to all connected clients
46 | server.send("hello everyone");
47 |
48 | // ...simulate an error and close the connection
49 | server.error();
50 |
51 | // ...or gracefully close the connection
52 | server.close();
53 |
54 | // The WS class also has a static "clean" method to gracefully close all open connections,
55 | // particularly useful to reset the environment between test runs.
56 | WS.clean();
57 | ```
58 |
59 | The `WS` constructor also accepts an optional options object as second argument:
60 |
61 | - `jsonProtocol: true` can be used to automatically serialize and deserialize JSON messages:
62 |
63 | ```js
64 | const server = new WS("ws://localhost:1234", { jsonProtocol: true });
65 | server.send({ type: "GREETING", payload: "hello" });
66 | ```
67 |
68 | - The `mock-server` options `verifyClient` and `selectProtocol` are directly passed-through to the mock-server's constructor.
69 |
70 | ### Attributes of a `WS` instance
71 |
72 | A `WS` instance has the following attributes:
73 |
74 | - `connected`: a Promise that resolves every time the `WS` instance receives a
75 | new connection. The resolved value is the `WebSocket` instance that initiated
76 | the connection.
77 | - `closed`: a Promise that resolves every time a connection to a `WS` instance
78 | is closed.
79 | - `nextMessage`: a Promise that resolves every time a `WS` instance receives a
80 | new message. The resolved value is the received message (deserialized as a
81 | JavaScript Object if the `WS` was instantiated with the `{ jsonProtocol: true }`
82 | option).
83 |
84 | ### Methods on a `WS` instance
85 |
86 | - `send`: send a message to all connected clients. (The message will be
87 | serialized from a JavaScript Object to a JSON string if the `WS` was
88 | instantiated with the `{ jsonProtocol: true }` option).
89 | - `close`: gracefully closes all opened connections.
90 | - `error`: sends an error message to all connected clients and closes all
91 | opened connections.
92 | - `on`: attach event listeners to handle new `connection`, `message` and `close` events. The callback receives the `socket` as its only argument.
93 |
94 | ## Run assertions on received messages
95 |
96 | `jest-websocket-mock` registers custom jest matchers to make assertions
97 | on received messages easier:
98 |
99 | - `.toReceiveMessage`: async matcher that waits for the next message received
100 | by the the mock websocket server, and asserts its content. It will time out
101 | with a helpful message after 1000ms.
102 | - `.toHaveReceivedMessages`: synchronous matcher that checks that all the
103 | expected messages have been received by the mock websocket server.
104 |
105 | ### Run assertions on messages as they are received by the mock server
106 |
107 | ```js
108 | test("the server keeps track of received messages, and yields them as they come in", async () => {
109 | const server = new WS("ws://localhost:1234");
110 | const client = new WebSocket("ws://localhost:1234");
111 |
112 | await server.connected;
113 | client.send("hello");
114 | await expect(server).toReceiveMessage("hello");
115 | expect(server).toHaveReceivedMessages(["hello"]);
116 | });
117 | ```
118 |
119 | ### Send messages to the connected clients
120 |
121 | ```js
122 | test("the mock server sends messages to connected clients", async () => {
123 | const server = new WS("ws://localhost:1234");
124 | const client1 = new WebSocket("ws://localhost:1234");
125 | await server.connected;
126 | const client2 = new WebSocket("ws://localhost:1234");
127 | await server.connected;
128 |
129 | const messages = { client1: [], client2: [] };
130 | client1.onmessage = (e) => {
131 | messages.client1.push(e.data);
132 | };
133 | client2.onmessage = (e) => {
134 | messages.client2.push(e.data);
135 | };
136 |
137 | server.send("hello everyone");
138 | expect(messages).toEqual({
139 | client1: ["hello everyone"],
140 | client2: ["hello everyone"],
141 | });
142 | });
143 | ```
144 |
145 | ### JSON protocols support
146 |
147 | `jest-websocket-mock` can also automatically serialize and deserialize
148 | JSON messages:
149 |
150 | ```js
151 | test("the mock server seamlessly handles JSON protocols", async () => {
152 | const server = new WS("ws://localhost:1234", { jsonProtocol: true });
153 | const client = new WebSocket("ws://localhost:1234");
154 |
155 | await server.connected;
156 | client.send(`{ "type": "GREETING", "payload": "hello" }`);
157 | await expect(server).toReceiveMessage({ type: "GREETING", payload: "hello" });
158 | expect(server).toHaveReceivedMessages([
159 | { type: "GREETING", payload: "hello" },
160 | ]);
161 |
162 | let message = null;
163 | client.onmessage = (e) => {
164 | message = e.data;
165 | };
166 |
167 | server.send({ type: "CHITCHAT", payload: "Nice weather today" });
168 | expect(message).toEqual(`{"type":"CHITCHAT","payload":"Nice weather today"}`);
169 | });
170 | ```
171 |
172 | ### verifyClient server option
173 |
174 | A `verifyClient` function can be given in the options for the `jest-websocket-mock` constructor.
175 | This can be used to test behaviour for a client that connects to a WebSocket server it's blacklisted from for example.
176 |
177 | **Note** : _Currently `mock-socket`'s implementation does not send any parameters to this function (unlike the real `ws` implementation)._
178 |
179 | ```js
180 | test("rejects connections that fail the verifyClient option", async () => {
181 | new WS("ws://localhost:1234", { verifyClient: () => false });
182 | const errorCallback = jest.fn();
183 |
184 | await expect(
185 | new Promise((resolve, reject) => {
186 | errorCallback.mockImplementation(reject);
187 | const client = new WebSocket("ws://localhost:1234");
188 | client.onerror = errorCallback;
189 | client.onopen = resolve;
190 | })
191 | // WebSocket onerror event gets called with an event of type error and not an error
192 | ).rejects.toEqual(expect.objectContaining({ type: "error" }));
193 | });
194 | ```
195 |
196 | ### selectProtocol server option
197 |
198 | A `selectProtocol` function can be given in the options for the `jest-websocket-mock` constructor.
199 | This can be used to test behaviour for a client that connects to a WebSocket server using the wrong protocol.
200 |
201 | ```js
202 | test("rejects connections that fail the selectProtocol option", async () => {
203 | const selectProtocol = () => null;
204 | new WS("ws://localhost:1234", { selectProtocol });
205 | const errorCallback = jest.fn();
206 |
207 | await expect(
208 | new Promise((resolve, reject) => {
209 | errorCallback.mockImplementationOnce(reject);
210 | const client = new WebSocket("ws://localhost:1234", "foo");
211 | client.onerror = errorCallback;
212 | client.onopen = resolve;
213 | })
214 | ).rejects.toEqual(
215 | // WebSocket onerror event gets called with an event of type error and not an error
216 | expect.objectContaining({
217 | type: "error",
218 | currentTarget: expect.objectContaining({ protocol: "foo" }),
219 | })
220 | );
221 | });
222 | ```
223 |
224 | ### Sending errors
225 |
226 | ```js
227 | test("the mock server sends errors to connected clients", async () => {
228 | const server = new WS("ws://localhost:1234");
229 | const client = new WebSocket("ws://localhost:1234");
230 | await server.connected;
231 |
232 | let disconnected = false;
233 | let error = null;
234 | client.onclose = () => {
235 | disconnected = true;
236 | };
237 | client.onerror = (e) => {
238 | error = e;
239 | };
240 |
241 | server.send("hello everyone");
242 | server.error();
243 | expect(disconnected).toBe(true);
244 | expect(error.origin).toBe("ws://localhost:1234/");
245 | expect(error.type).toBe("error");
246 | });
247 | ```
248 |
249 | ### Add custom event listeners
250 |
251 | #### For instance, to refuse connections:
252 |
253 | ```js
254 | it("the server can refuse connections", async () => {
255 | const server = new WS("ws://localhost:1234");
256 | server.on("connection", (socket) => {
257 | socket.close({ wasClean: false, code: 1003, reason: "NOPE" });
258 | });
259 |
260 | const client = new WebSocket("ws://localhost:1234");
261 | client.onclose = (event: CloseEvent) => {
262 | expect(event.code).toBe(1003);
263 | expect(event.wasClean).toBe(false);
264 | expect(event.reason).toBe("NOPE");
265 | };
266 |
267 | expect(client.readyState).toBe(WebSocket.CONNECTING);
268 |
269 | await server.connected;
270 | expect(client.readyState).toBe(WebSocket.CLOSING);
271 |
272 | await server.closed;
273 | expect(client.readyState).toBe(WebSocket.CLOSED);
274 | });
275 | ```
276 |
277 | ### Environment set up and tear down between tests
278 |
279 | You can set up a mock server and a client, and reset them between tests:
280 |
281 | ```js
282 | beforeEach(async () => {
283 | server = new WS("ws://localhost:1234");
284 | client = new WebSocket("ws://localhost:1234");
285 | await server.connected;
286 | });
287 |
288 | afterEach(() => {
289 | WS.clean();
290 | });
291 | ```
292 |
293 | ## Known issues
294 |
295 | `mock-socket` has a strong usage of delays (`setTimeout` to be more specific). This means using `jest.useFakeTimers();` will cause issues such as the client appearing to never connect to the server.
296 |
297 | ## Testing React applications
298 |
299 | When testing React applications, `jest-websocket-mock` will look for
300 | `@testing-library/react`'s implementation of [`act`](https://reactjs.org/docs/test-utils.html#act).
301 | If it is available, it will wrap all the necessary calls in `act`, so you don't have to.
302 |
303 | If `@testing-library/react` is not available, we will assume that you're not testing a React application,
304 | and you might need to call `act` manually.
305 |
306 | ## Using `jest-websocket-mock` to interact with a non-global WebSocket object
307 |
308 | `jest-websocket-mock` uses [Mock Socket](https://github.com/thoov/mock-socket)
309 | under the hood to mock out WebSocket clients.
310 | Out of the box, Mock Socket will only mock out the global `WebSocket` object.
311 | If you are using a third-party WebSocket client library (eg. a Node.js
312 | implementation, like [`ws`](https://github.com/websockets/ws)), you'll need
313 | to set up a [manual mock](https://jestjs.io/docs/en/manual-mocks#mocking-node-modules):
314 |
315 | - Create a `__mocks__` folder in your project root
316 | - Add a new file in the `__mocks__` folder named after the library you want to
317 | mock out. For instance, for the `ws` library: `__mocks__/ws.js`.
318 | - Export Mock Socket's implementation in-lieu of the normal export from the
319 | library you want to mock out. For instance, for the `ws` library:
320 |
321 | ```js
322 | // __mocks__/ws.js
323 |
324 | export { WebSocket as default } from "mock-socket";
325 | ```
326 |
327 | **NOTE** The `ws` library is not 100% compatible with the browser API, and
328 | the `mock-socket` library that `jest-websocket-mock` uses under the hood only
329 | implements the browser API.
330 | As a result, `jest-websocket-mock` will only work with the `ws` library if you
331 | restrict yourself to the browser APIs!
332 |
333 | ## Examples
334 |
335 | For a real life example, see the
336 | [examples directory](https://github.com/romgain/jest-websocket-mock/tree/master/examples),
337 | and in particular the saga tests.
338 |
339 | ## Contributing
340 |
341 | See the [contributing guide](https://github.com/romgain/jest-websocket-mock/tree/master/CONTRIBUTING.md).
342 |
--------------------------------------------------------------------------------