From 5aa2913bce5c77eb83b8cc94ec0fdbac1af75c50 Mon Sep 17 00:00:00 2001 From: Don Isaac Date: Mon, 3 Mar 2025 20:30:47 -0800 Subject: [PATCH] test(bun/net): add TCP socket tests (#17520) --- test/js/bun/net/tcp.spec.ts | 232 ++++++++++++++++++++++++++++++++++++ test/js/bun/net/tcp.test.ts | 186 +++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 test/js/bun/net/tcp.spec.ts create mode 100644 test/js/bun/net/tcp.test.ts diff --git a/test/js/bun/net/tcp.spec.ts b/test/js/bun/net/tcp.spec.ts new file mode 100644 index 0000000000..03492b4ad4 --- /dev/null +++ b/test/js/bun/net/tcp.spec.ts @@ -0,0 +1,232 @@ +import { Socket, SocketHandler, type TCPSocketListener } from "bun"; +import { jest, describe, beforeAll, afterAll, beforeEach, afterEach, it, expect } from "bun:test"; + +const createMockHandler = () => + ({ + close: jest.fn(), + connectError: jest.fn(), + data: jest.fn(), + drain: jest.fn(), + end: jest.fn(), + error: jest.fn(), + handshake: jest.fn(), + open: jest.fn(), + timeout: jest.fn(), + }) satisfies SocketHandler; + +const clearMockHandler = (handler: SocketHandler) => { + for (const key in handler) { + if ("mockClear" in handler[key]) handler[key].mockClear(); + } +}; + +const nextEventLoopTick = () => new Promise(resolve => setTimeout(resolve, 0)); +describe("Bun.listen(options)", () => { + describe("socket options", () => { + // @ts-expect-error + it("must be provided", () => expect(() => Bun.listen()).toThrow(TypeError)); + it.each([undefined, null, 1, true, function foo() {}, "foo", Symbol.for("hi")])( + "must be an object (%p)", + // @ts-expect-error + badOptions => expect(() => Bun.listen(badOptions)).toThrow(TypeError), + ); + }); // + + describe("When called with valid options", () => { + let listener: TCPSocketListener; + + beforeAll(() => { + listener = Bun.listen({ + port: 6543, + hostname: "localhost", + socket: { + data(socket, data) {}, + }, + }); + }); + + afterAll(() => { + expect(listener).toBeDefined(); + listener.stop(); + listener = undefined as any; + }); + + it("returns an object", () => expect(listener).toBeTypeOf("object")); + + // FIXME: this is overriding properties of `listener` + it.skip("listener has the expected methods", () => { + expect(listener).toMatchObject({ + stop: expect.any(Function), + ref: expect.any(Function), + unref: expect.any(Function), + reload: expect.any(Function), + }); + }); + + it("is listening on localhost:6543", () => { + expect(listener.port).toBe(6543); + expect(listener.hostname).toBe("localhost"); + }); + + it("does not have .data", () => { + expect(listener).toHaveProperty("data"); + expect(listener.data).toBeUndefined(); + }); + }); // +}); // + +describe("Given a TCP server listening on port 1234", () => { + let listener: TCPSocketListener; + const serverHandler = createMockHandler(); + + // FIXME: switching this to `beforeAll` then using `listener.reload() in + // `beforeEach` causes a segfault. + beforeEach(() => { + listener = Bun.listen({ + hostname: "localhost", + port: 0, + socket: serverHandler, + }); + }); + + afterEach(() => { + listener.stop(true); + clearMockHandler(serverHandler); + // listener.reload({ socket: serverHandler }); + }); + + describe("When a client connects and waits 1 event loop cycle", () => { + let client: Socket; + const events = { + client: [] as string[], + server: [] as string[], + }; + const clientHandler = createMockHandler(); + const getClient = (port: number) => + Bun.connect({ + hostname: "localhost", + port, + socket: clientHandler, + }); + + beforeEach(async () => { + client = await getClient(listener.port); + + for (const event of Object.keys(clientHandler)) { + if (typeof clientHandler[event] === "function") { + clientHandler[event].mockImplementation(() => events.client.push(event)); + } + } + + for (const event of Object.keys(serverHandler)) { + if (typeof serverHandler[event] === "function") { + serverHandler[event].mockImplementation(() => events.server.push(event)); + } + } + + await nextEventLoopTick(); + }); + + afterEach(() => { + client.end(); + events.client.length = 0; + events.server.length = 0; + clearMockHandler(clientHandler); + }); + + // FIXME: readyState is 1. + it.skip("the client enters 'open' state", () => { + expect(client.readyState).toBe("open"); + }); + + it("client.open() gets called", () => expect(clientHandler.open).toHaveBeenCalledTimes(1)); + it("server.open() gets called", () => expect(serverHandler.open).toHaveBeenCalledTimes(1)); + + it.each(["handshake", "close", "error", "end"])( + "neither client nor server's %s handler is called", + async handler => { + expect(clientHandler[handler]).not.toHaveBeenCalled(); + expect(serverHandler[handler]).not.toHaveBeenCalled(); + }, + ); + + it("has sent no data", () => expect(client.bytesWritten).toBe(0)); + + it("when the client sends data, the server's data handler gets called after data are flushed", async () => { + const bytesWritten = client.write("hello"); + expect(bytesWritten).toBe(5); + expect(serverHandler.data).not.toHaveBeenCalled(); + client.flush(); + expect(serverHandler.data).not.toHaveBeenCalled(); + await nextEventLoopTick(); + expect(serverHandler.data).toHaveBeenCalledTimes(1); + }); + + // FIXME: three bugs: + // 1&2. client/server handshake callbacks are not called + // 3. un-commenting this and moving Bun.listen into `beforeAll` causes the + // `expect(server.end).toHaveBeenCalledTimes(1)` in the neighboring test + // to fail (it gets called twice) + // + describe.skip("on the next event loop cycle", () => { + beforeEach(nextEventLoopTick); + it("server.handshake() gets called", async () => { + expect(serverHandler.handshake).toHaveBeenCalled(); + }); + it("client.handshake() gets called", async () => { + expect(clientHandler.handshake).toHaveBeenCalled(); + }); + }); // + + describe("When the client disconnects", () => { + beforeEach(() => { + client.end(); + }); + + // FIXME: readyState is -1. + it.skip("client enters 'closing' state", () => { + expect(client.readyState).toBe("closing"); + }); + + describe("On the next event loop cycle", () => { + beforeEach(nextEventLoopTick); + + it("the server's end handler fires", () => { + expect(serverHandler.end).toHaveBeenCalledTimes(1); + }); + + it("the server's close handler fires after end", () => { + expect(serverHandler.close).toHaveBeenCalledTimes(1); + const endIndex = events.server.indexOf("end"); + const closeIndex = events.server.indexOf("close"); + expect(closeIndex).toBeGreaterThan(endIndex); + }); + + it("no client errors occur", () => { + expect(clientHandler.error).not.toHaveBeenCalled(); + expect(clientHandler.connectError).not.toHaveBeenCalled(); + }); + + it("no server errors occur", () => { + expect(serverHandler.error).not.toHaveBeenCalled(); + expect(serverHandler.connectError).not.toHaveBeenCalled(); + }); + + it("can no longer send data", () => { + expect(client.write("hello")).toBeLessThan(0); + }); + + // FIXME: readyState is detached (-1) + it.skip("client is closed", () => { + expect(client.readyState).toBe("closed"); + }); + + // FIXME: readyState is -1. + it.skip("calling client.end() twice does nothing", () => { + client.end(); + expect(client.readyState).toBe("closed"); + }); + }); + }); + }); // +}); // diff --git a/test/js/bun/net/tcp.test.ts b/test/js/bun/net/tcp.test.ts new file mode 100644 index 0000000000..b66f900c89 --- /dev/null +++ b/test/js/bun/net/tcp.test.ts @@ -0,0 +1,186 @@ +import { Socket, SocketHandler, TCPSocketConnectOptions, TCPSocketListener } from "bun"; +import type { Mock } from "bun:test"; +import { jest, afterEach, test, expect } from "bun:test"; +import { isLinux } from "harness"; +import jsc from "bun:jsc"; + +const handlerNames: Set> = new Set([ + "open", + "handshake", + "close", + "error", + "end", + "data", + "drain", +]); + +class MockSocket implements SocketHandler { + open = undefined as any; + handshake = undefined as any; + close = undefined as any; + error = undefined as any; + end = undefined as any; + data = undefined as any; + + events: string[]; + + constructor(impls: Partial> = {}) { + this.events = []; + for (const method of handlerNames) { + const impl = impls[method] ? impls[method].bind(this) : () => this.events.push(method); + this[method] = jest.fn(impl as any); + } + } + + public mockClear() { + for (const method of Object.keys(this)) { + if ("mockClear" in this[method]) { + (this[method] as Mock).mockClear(); + } + } + this.events.length = 0; + } +} + +const nextEventLoopCycle = () => new Promise(resolve => setTimeout(resolve, 0)); +const nextTick = () => new Promise(resolve => process.nextTick(resolve)); + +interface ClientState extends Disposable { + socket: Socket; + handler: MockSocket; +} + +const isServer = (value: unknown): value is TCPSocketListener => + !!value && typeof value === "object" && "stop" in value && "ref" in value && "reload" in value; + +function makeClient( + handlerOverrides?: Partial>, + options?: TCPSocketConnectOptions, +): Promise>; +function makeClient(server: TCPSocketListener): Promise>; +async function makeClient( + serverOrHandlerOverrides?: Partial>, + options: Partial> = {}, +) { + let handlerOverrides: Partial> | undefined; + if (isServer(serverOrHandlerOverrides)) { + options.port = serverOrHandlerOverrides.port; + options.hostname = serverOrHandlerOverrides.hostname; + } else { + handlerOverrides = serverOrHandlerOverrides; + if (options.port == null) throw new Error("port is required"); + } + const handler = new MockSocket(handlerOverrides); + const socket = await Bun.connect({ + hostname: "localhost", + // port: 0, + ...options, + socket: handler, + } as any); + + return { + socket, + handler, + [Symbol.dispose]() { + socket[Symbol.dispose](); + }, + }; +} + +const makeServer = (handlerOverrides?: Partial>, options?) => { + const handler = new MockSocket(handlerOverrides); + const socket: TCPSocketListener = Bun.listen({ + hostname: "localhost", + port: 0, + ...options, + socket: handler, + }); + return { + socket, + handler, + [Symbol.dispose]() { + socket[Symbol.dispose](); + }, + }; +}; + +afterEach(async () => { + await nextEventLoopCycle(); + jsc.drainMicrotasks(); +}); + +test("open() event timing", async () => { + const socket = { server: new MockSocket(), client: new MockSocket() }; + + using server = Bun.listen({ + port: 0, + hostname: "localhost", + socket: socket.server, + }); + + // just starting a server doesn't trigger any events + await nextEventLoopCycle(); + expect(socket.server.events).toBeEmpty(); + expect(socket.client.events).toBeEmpty(); + + const clientPromise = Bun.connect({ + port: server.port, + hostname: "localhost", + socket: socket.client, + }); + expect(socket.client.open).not.toHaveBeenCalled(); + await nextTick(); + expect(socket.client.open).not.toHaveBeenCalled(); + + // Promise resolves when client connects. Server's open only fires when + // event loop polls for events again and finds a connection event on the + // server socket + using _client = await clientPromise; + expect(socket.client.open).toHaveBeenCalled(); + // FIXME: server's open handler is called on linux, but not on macOS or windows. + if (!isLinux) expect(socket.server.open).not.toHaveBeenCalled(); + + // next tick loop gets drained before event loop polls again. This check makes + // sure that open(), indeed, only fires in the next event loop cycle + await nextTick(); + // FIXME: server's open handler is called on linux, but not on macOS or windows. + if (!isLinux) expect(socket.server.open).not.toHaveBeenCalled(); + + await nextEventLoopCycle(); + expect(socket.server.open).toHaveBeenCalled(); + expect(socket.client.open).toHaveBeenCalled(); +}); + +/** + * Client sends FIN, so server emits `end` event. Client still reads data before + * closing. + */ +test("client writes then closes the socket", async () => { + using server = makeServer({ + data(_socket, data) { + expect(data.toString("utf8")).toBe("hello"); + this.events.push("data"); + }, + }); + using client = await makeClient(server.socket); + + server.socket; + client.socket.end("hello"); + await nextEventLoopCycle(); + expect(server.handler.events).toEqual(["open", "data", "end", "close"]); + expect(client.handler.events).toEqual(["open", "close"]); +}); + +test.skip("client writes while server closes in the same tick", async () => { + using server = makeServer({ + open(socket) { + socket.write("hello"); + socket.end(); + this.events.push("open"); + }, + }); + using client = await makeClient(server.socket); + await nextEventLoopCycle(); + expect(server.handler.events).toEqual(["open", "close"]); + expect(client.handler.events).toEqual(["open", "data", "end", "close"]); +});