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)