Skip to content

fix(site): add tests for createMockWebSocket #19172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Aug 7, 2025
Merged
Next Next commit
wip: commit progress on tests
  • Loading branch information
Parkreiner committed Aug 4, 2025
commit a87e93e3f12338d458439537cd0bfe509ced813d
135 changes: 135 additions & 0 deletions site/src/testHelpers/websockets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { createMockWebSocket } from "./websockets";

describe(createMockWebSocket.name, () => {
it("Throws if URL does not have ws:// or wss:// protocols", () => {
const urls: readonly string[] = [
"http://www.dog.ceo/roll-over",
"https://www.dog.ceo/roll-over"
];
for (const url of urls) {
expect(() => {
void createMockWebSocket(url);
}).toThrow("URL must start with ws:// or wss://");
}
})

it("Sends events from publisher to socket", () => {
const [socket, publisher] = createMockWebSocket("wss://www.dog.ceo/shake");

const onOpen = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onClose = jest.fn();

socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
socket.addEventListener("message", onMessage);
socket.addEventListener("close", onClose);

const openEvent = new Event("open");
const errorEvent = new Event("error");
const messageEvent = new MessageEvent<string>("message")
const closeEvent = new CloseEvent("close");

publisher.publishOpen(openEvent);
publisher.publishError(errorEvent);
publisher.publishMessage(messageEvent);
publisher.publishClose(closeEvent);

expect(onOpen).toHaveBeenCalledTimes(1);
expect(onOpen).toHaveBeenCalledWith(openEvent);

expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith(errorEvent);

expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(messageEvent);

expect(onClose).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledWith(closeEvent);
Comment on lines +48 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this test makes me feel like I'm looking at a freshly ironed shirt. super nice!

});

it("Sends JSON data to the socket for message events", () => {
const [socket, publisher] = createMockWebSocket("wss://www.dog.ceo/wag");
const onMessage = jest.fn();

// Could type this as a special JSON type, but unknown is good enough,
// since any invalid values will throw in the test case
const jsonData: readonly unknown[] =[
"blah",
42,
true,
false,
null,
{},
[],
[{ value: "blah" }, { value: "guh" }, { value: "huh" }],
{
name: "Hershel Layton",
age: 40,
profession: "Puzzle Solver",
sadBackstory: true,
greatVideoGames: true,
}
];
for (const jd of jsonData) {
socket.addEventListener("message", onMessage);
publisher.publishMessage(new MessageEvent<string>("message", {
"data": JSON.stringify(jd)
}));

expect(onMessage).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledWith(new MessageEvent<string>("message", {
data: JSON.stringify(jd)
}));

socket.removeEventListener("message", onMessage);
onMessage.mockClear();
}
})

it("Only registers each socket event handler once", () => {
const [socket, publisher] = createMockWebSocket("wss://www.dog.ceo/borf");

const onOpen = jest.fn();
const onError = jest.fn();
const onMessage = jest.fn();
const onClose = jest.fn();

// Do it once
socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
socket.addEventListener("message", onMessage);
socket.addEventListener("close", onClose);

// Do it again with the exact same functions
socket.addEventListener("open", onOpen);
socket.addEventListener("error", onError);
socket.addEventListener("message", onMessage);
socket.addEventListener("close", onClose);

publisher.publishOpen(new Event("open"));
publisher.publishError(new Event("error"));
publisher.publishMessage(new MessageEvent<string>("message"));
publisher.publishClose(new CloseEvent("close"));

expect(onOpen).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledTimes(1);
expect(onMessage).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
});

it("Renders socket inert after being closed", () => {
const [socket, publisher] = createMockWebSocket("wss://www.dog.ceo/woof");
expect(publisher.isConnectionOpen).toBe(true);

const onMessage = jest.fn();
socket.addEventListener("message", onMessage);

socket.close();
expect(publisher.isConnectionOpen).toBe(false);

publisher.publishMessage(new MessageEvent<string>("message"));
expect(onMessage).not.toHaveBeenCalled();
});
});
138 changes: 138 additions & 0 deletions site/src/testHelpers/websockets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { WebSocketEventType } from "utils/OneWayWebSocket";

export type MockWebSocketPublisher = Readonly<{
publishMessage: (event: MessageEvent<string>) => void;
publishError: (event: Event) => void;
publishClose: (event: CloseEvent) => void;
publishOpen: (event: Event) => void;
readonly isConnectionOpen: boolean;
}>;

export function createMockWebSocket(
url: string,
protocols?: string | string[],
): readonly [WebSocket, MockWebSocketPublisher] {
type EventMap = {
message: MessageEvent<string>;
error: Event;
close: CloseEvent;
open: Event;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not just use the built-in WebSocketEventMap? just so that we can specify MessageEvent<string>? I just don't want us to stray too far from the actual types and have that weaken these tests

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of forgot the reason, but I think it was originally in service for the OneWayWebSocket implementation – that uses some generics, and I wanted to minimize the amount of mental type parameter juggling you'd have to do when reading the file

Now that this is split off, though, I'll see if I can remove these and still have the types line up

type CallbackStore = {
[K in keyof EventMap]: ((event: EventMap[K]) => void)[];
};

let activeProtocol: string;
if (Array.isArray(protocols)) {
activeProtocol = protocols[0] ?? "";
} else if (typeof protocols === "string") {
activeProtocol = protocols;
} else {
activeProtocol = "";
}

let isOpen = true;
const store: CallbackStore = {
message: [],
error: [],
close: [],
open: [],
};

const mockSocket: WebSocket = {
CONNECTING: 0,
OPEN: 1,
CLOSING: 2,
CLOSED: 3,

url,
protocol: activeProtocol,
readyState: 1,
binaryType: "blob",
bufferedAmount: 0,
extensions: "",
onclose: null,
onerror: null,
onmessage: null,
onopen: null,
send: jest.fn(),
dispatchEvent: jest.fn(),

addEventListener: <E extends WebSocketEventType>(
eventType: E,
callback: (event: WebSocketEventMap[E]) => void,
) => {
if (!isOpen) {
return;
}

const subscribers = store[eventType];
if (!subscribers.includes(callback)) {
subscribers.push(callback);
}
},

removeEventListener: <E extends WebSocketEventType>(
eventType: E,
callback: (event: WebSocketEventMap[E]) => void,
) => {
if (!isOpen) {
return;
}

const subscribers = store[eventType];
if (subscribers.includes(callback)) {
const updated = store[eventType].filter((c) => c !== callback);
store[eventType] = updated as CallbackStore[E];
}
},

close: () => {
isOpen = false;
},
};

const publisher: MockWebSocketPublisher = {
get isConnectionOpen() {
return isOpen;
},

publishOpen: (event) => {
if (!isOpen) {
return;
}
for (const sub of store.open) {
sub(event);
}
},

publishError: (event) => {
if (!isOpen) {
return;
}
for (const sub of store.error) {
sub(event);
}
},

publishMessage: (event) => {
if (!isOpen) {
return;
}
for (const sub of store.message) {
sub(event);
}
},

publishClose: (event) => {
if (!isOpen) {
return;
}
for (const sub of store.close) {
sub(event);
}
},
};

return [mockSocket, publisher] as const;
}