Skip to content

Commit 5225c56

Browse files
authored
fix(site): add tests for createMockWebSocket (#19172)
Needed for #19126 and #18679 ## Changes made - Moved `createWebSocket` to dedicated file and addressed edge cases for making it a reliable mock - Added test cases to validate mock functionality
1 parent 0a3afed commit 5225c56

File tree

3 files changed

+388
-174
lines changed

3 files changed

+388
-174
lines changed
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { createMockWebSocket } from "./websockets";
2+
3+
describe(createMockWebSocket.name, () => {
4+
it("Throws if URL does not have ws:// or wss:// protocols", () => {
5+
const urls: readonly string[] = [
6+
"http://www.dog.ceo/roll-over",
7+
"https://www.dog.ceo/roll-over",
8+
];
9+
for (const url of urls) {
10+
expect(() => {
11+
void createMockWebSocket(url);
12+
}).toThrow("URL must start with ws:// or wss://");
13+
}
14+
});
15+
16+
it("Sends events from server to socket", () => {
17+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/shake");
18+
19+
const onOpen = jest.fn();
20+
const onError = jest.fn();
21+
const onMessage = jest.fn();
22+
const onClose = jest.fn();
23+
24+
socket.addEventListener("open", onOpen);
25+
socket.addEventListener("error", onError);
26+
socket.addEventListener("message", onMessage);
27+
socket.addEventListener("close", onClose);
28+
29+
const openEvent = new Event("open");
30+
const errorEvent = new Event("error");
31+
const messageEvent = new MessageEvent<string>("message");
32+
const closeEvent = new CloseEvent("close");
33+
34+
server.publishOpen(openEvent);
35+
server.publishError(errorEvent);
36+
server.publishMessage(messageEvent);
37+
server.publishClose(closeEvent);
38+
39+
expect(onOpen).toHaveBeenCalledTimes(1);
40+
expect(onOpen).toHaveBeenCalledWith(openEvent);
41+
42+
expect(onError).toHaveBeenCalledTimes(1);
43+
expect(onError).toHaveBeenCalledWith(errorEvent);
44+
45+
expect(onMessage).toHaveBeenCalledTimes(1);
46+
expect(onMessage).toHaveBeenCalledWith(messageEvent);
47+
48+
expect(onClose).toHaveBeenCalledTimes(1);
49+
expect(onClose).toHaveBeenCalledWith(closeEvent);
50+
});
51+
52+
it("Sends JSON data to the socket for message events", () => {
53+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/wag");
54+
const onMessage = jest.fn();
55+
56+
// Could type this as a special JSON type, but unknown is good enough,
57+
// since any invalid values will throw in the test case
58+
const jsonData: readonly unknown[] = [
59+
"blah",
60+
42,
61+
true,
62+
false,
63+
null,
64+
{},
65+
[],
66+
[{ value: "blah" }, { value: "guh" }, { value: "huh" }],
67+
{
68+
name: "Hershel Layton",
69+
age: 40,
70+
profession: "Puzzle Solver",
71+
sadBackstory: true,
72+
greatVideoGames: true,
73+
},
74+
];
75+
for (const jd of jsonData) {
76+
socket.addEventListener("message", onMessage);
77+
server.publishMessage(
78+
new MessageEvent("message", { data: JSON.stringify(jd) }),
79+
);
80+
81+
expect(onMessage).toHaveBeenCalledTimes(1);
82+
expect(onMessage).toHaveBeenCalledWith(
83+
new MessageEvent("message", { data: JSON.stringify(jd) }),
84+
);
85+
86+
socket.removeEventListener("message", onMessage);
87+
onMessage.mockClear();
88+
}
89+
});
90+
91+
it("Only registers each socket event handler once", () => {
92+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/borf");
93+
94+
const onOpen = jest.fn();
95+
const onError = jest.fn();
96+
const onMessage = jest.fn();
97+
const onClose = jest.fn();
98+
99+
// Do it once
100+
socket.addEventListener("open", onOpen);
101+
socket.addEventListener("error", onError);
102+
socket.addEventListener("message", onMessage);
103+
socket.addEventListener("close", onClose);
104+
105+
// Do it again with the exact same functions
106+
socket.addEventListener("open", onOpen);
107+
socket.addEventListener("error", onError);
108+
socket.addEventListener("message", onMessage);
109+
socket.addEventListener("close", onClose);
110+
111+
server.publishOpen(new Event("open"));
112+
server.publishError(new Event("error"));
113+
server.publishMessage(new MessageEvent<string>("message"));
114+
server.publishClose(new CloseEvent("close"));
115+
116+
expect(onOpen).toHaveBeenCalledTimes(1);
117+
expect(onError).toHaveBeenCalledTimes(1);
118+
expect(onMessage).toHaveBeenCalledTimes(1);
119+
expect(onClose).toHaveBeenCalledTimes(1);
120+
});
121+
122+
it("Lets a socket unsubscribe to event types", () => {
123+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/zoomies");
124+
125+
const onOpen = jest.fn();
126+
const onError = jest.fn();
127+
const onMessage = jest.fn();
128+
const onClose = jest.fn();
129+
130+
socket.addEventListener("open", onOpen);
131+
socket.addEventListener("error", onError);
132+
socket.addEventListener("message", onMessage);
133+
socket.addEventListener("close", onClose);
134+
135+
socket.removeEventListener("open", onOpen);
136+
socket.removeEventListener("error", onError);
137+
socket.removeEventListener("message", onMessage);
138+
socket.removeEventListener("close", onClose);
139+
140+
server.publishOpen(new Event("open"));
141+
server.publishError(new Event("error"));
142+
server.publishMessage(new MessageEvent<string>("message"));
143+
server.publishClose(new CloseEvent("close"));
144+
145+
expect(onOpen).not.toHaveBeenCalled();
146+
expect(onError).not.toHaveBeenCalled();
147+
expect(onMessage).not.toHaveBeenCalled();
148+
expect(onClose).not.toHaveBeenCalled();
149+
});
150+
151+
it("Renders socket inert after being closed", () => {
152+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/woof");
153+
expect(server.isConnectionOpen).toBe(true);
154+
155+
const onMessage = jest.fn();
156+
socket.addEventListener("message", onMessage);
157+
158+
socket.close();
159+
expect(server.isConnectionOpen).toBe(false);
160+
161+
server.publishMessage(new MessageEvent<string>("message"));
162+
expect(onMessage).not.toHaveBeenCalled();
163+
});
164+
165+
it("Tracks arguments sent by the mock socket", () => {
166+
const [socket, server] = createMockWebSocket("wss://www.dog.ceo/wan-wan");
167+
const data = JSON.stringify({
168+
famousDogs: [
169+
"snoopy",
170+
"clifford",
171+
"lassie",
172+
"beethoven",
173+
"courage the cowardly dog",
174+
],
175+
});
176+
177+
socket.send(data);
178+
expect(server.clientSentData).toHaveLength(1);
179+
expect(server.clientSentData).toEqual([data]);
180+
181+
socket.close();
182+
socket.send(data);
183+
expect(server.clientSentData).toHaveLength(1);
184+
expect(server.clientSentData).toEqual([data]);
185+
});
186+
});

site/src/testHelpers/websockets.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import type { WebSocketEventType } from "utils/OneWayWebSocket";
2+
3+
type SocketSendData = Parameters<WebSocket["send"]>[0];
4+
5+
export type MockWebSocketServer = Readonly<{
6+
publishMessage: (event: MessageEvent<string>) => void;
7+
publishError: (event: Event) => void;
8+
publishClose: (event: CloseEvent) => void;
9+
publishOpen: (event: Event) => void;
10+
11+
readonly isConnectionOpen: boolean;
12+
readonly clientSentData: readonly SocketSendData[];
13+
}>;
14+
15+
type CallbackStore = {
16+
[K in keyof WebSocketEventMap]: Set<(event: WebSocketEventMap[K]) => void>;
17+
};
18+
19+
type MockWebSocket = Omit<WebSocket, "send"> & {
20+
/**
21+
* A version of the WebSocket `send` method that has been pre-wrapped inside
22+
* a Jest mock.
23+
*
24+
* The Jest mock functionality should be used at a minimum. Basically:
25+
* 1. If you want to check that the mock socket sent something to the mock
26+
* server: call the `send` method as a function, and then check the
27+
* `clientSentData` on `MockWebSocketServer` to see what data got
28+
* received.
29+
* 2. If you need to make sure that the client-side `send` method got called
30+
* at all: you can use the Jest mock functionality, but you should
31+
* probably also be checking `clientSentData` still and making additional
32+
* assertions with it.
33+
*
34+
* Generally, tests should center around whether socket-to-server
35+
* communication was successful, not whether the client-side method was
36+
* called.
37+
*/
38+
send: jest.Mock<void, [SocketSendData], unknown>;
39+
};
40+
41+
export function createMockWebSocket(
42+
url: string,
43+
protocol?: string | string[] | undefined,
44+
): readonly [MockWebSocket, MockWebSocketServer] {
45+
if (!url.startsWith("ws://") && !url.startsWith("wss://")) {
46+
throw new Error("URL must start with ws:// or wss://");
47+
}
48+
49+
const activeProtocol = Array.isArray(protocol)
50+
? protocol.join(" ")
51+
: (protocol ?? "");
52+
53+
let isOpen = true;
54+
const store: CallbackStore = {
55+
message: new Set(),
56+
error: new Set(),
57+
close: new Set(),
58+
open: new Set(),
59+
};
60+
61+
const sentData: SocketSendData[] = [];
62+
63+
const mockSocket: MockWebSocket = {
64+
CONNECTING: 0,
65+
OPEN: 1,
66+
CLOSING: 2,
67+
CLOSED: 3,
68+
69+
url,
70+
protocol: activeProtocol,
71+
readyState: 1,
72+
binaryType: "blob",
73+
bufferedAmount: 0,
74+
extensions: "",
75+
onclose: null,
76+
onerror: null,
77+
onmessage: null,
78+
onopen: null,
79+
dispatchEvent: jest.fn(),
80+
81+
send: jest.fn((data) => {
82+
if (!isOpen) {
83+
return;
84+
}
85+
sentData.push(data);
86+
}),
87+
88+
addEventListener: <E extends WebSocketEventType>(
89+
eventType: E,
90+
callback: (event: WebSocketEventMap[E]) => void,
91+
) => {
92+
if (!isOpen) {
93+
return;
94+
}
95+
const subscribers = store[eventType];
96+
subscribers.add(callback);
97+
},
98+
99+
removeEventListener: <E extends WebSocketEventType>(
100+
eventType: E,
101+
callback: (event: WebSocketEventMap[E]) => void,
102+
) => {
103+
if (!isOpen) {
104+
return;
105+
}
106+
const subscribers = store[eventType];
107+
subscribers.delete(callback);
108+
},
109+
110+
close: () => {
111+
isOpen = false;
112+
},
113+
};
114+
115+
const publisher: MockWebSocketServer = {
116+
get isConnectionOpen() {
117+
return isOpen;
118+
},
119+
120+
get clientSentData() {
121+
return [...sentData];
122+
},
123+
124+
publishOpen: (event) => {
125+
if (!isOpen) {
126+
return;
127+
}
128+
for (const sub of store.open) {
129+
sub(event);
130+
}
131+
},
132+
133+
publishError: (event) => {
134+
if (!isOpen) {
135+
return;
136+
}
137+
for (const sub of store.error) {
138+
sub(event);
139+
}
140+
},
141+
142+
publishMessage: (event) => {
143+
if (!isOpen) {
144+
return;
145+
}
146+
for (const sub of store.message) {
147+
sub(event);
148+
}
149+
},
150+
151+
publishClose: (event) => {
152+
if (!isOpen) {
153+
return;
154+
}
155+
for (const sub of store.close) {
156+
sub(event);
157+
}
158+
},
159+
};
160+
161+
return [mockSocket, publisher] as const;
162+
}

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy