diff --git a/.changeset/configurable-connection-state.md b/.changeset/configurable-connection-state.md new file mode 100644 index 0000000..b790b37 --- /dev/null +++ b/.changeset/configurable-connection-state.md @@ -0,0 +1,5 @@ +--- +"partyserver": patch +--- + +Add `configurable: true` to the `state`, `setState`, `serializeAttachment`, and `deserializeAttachment` property descriptors on connection objects. This allows downstream consumers (like the Cloudflare Agents SDK) to redefine these properties with `Object.defineProperty` for namespacing or wrapping internal state storage. Default behavior is unchanged. diff --git a/.changeset/validate-room-or-basepath.md b/.changeset/validate-room-or-basepath.md new file mode 100644 index 0000000..29c4abd --- /dev/null +++ b/.changeset/validate-room-or-basepath.md @@ -0,0 +1,5 @@ +--- +"partysocket": patch +--- + +Throw a clear error when constructing a `PartySocket` without `room` or `basePath` (and without `startClosed: true`), instead of silently connecting to a malformed URL containing `"undefined"` as the room name. diff --git a/packages/partyserver/src/connection.ts b/packages/partyserver/src/connection.ts index b834f54..713f2f3 100644 --- a/packages/partyserver/src/connection.ts +++ b/packages/partyserver/src/connection.ts @@ -144,11 +144,13 @@ export const createLazyConnection = ( } }, state: { + configurable: true, get() { return ws.deserializeAttachment() as ConnectionState; } }, setState: { + configurable: true, value: function setState(setState: T | ConnectionSetStateFn) { let state: T; if (setState instanceof Function) { @@ -163,6 +165,7 @@ export const createLazyConnection = ( }, deserializeAttachment: { + configurable: true, value: function deserializeAttachment() { const attachment = attachments.get(ws); return (attachment.__user ?? null) as T; @@ -170,6 +173,7 @@ export const createLazyConnection = ( }, serializeAttachment: { + configurable: true, value: function serializeAttachment(attachment: T) { const setting = { ...attachments.get(ws), diff --git a/packages/partyserver/src/tests/index.test.ts b/packages/partyserver/src/tests/index.test.ts index 4311be8..6d133da 100644 --- a/packages/partyserver/src/tests/index.test.ts +++ b/packages/partyserver/src/tests/index.test.ts @@ -190,6 +190,110 @@ describe("Server", () => { return promise; }); + it("allows state and setState to be redefined on a connection", async () => { + const ctx = createExecutionContext(); + const request = new Request( + "http://example.com/parties/configurable-state/room1", + { + headers: { + Upgrade: "websocket" + } + } + ); + const response = await worker.fetch(request, env, ctx); + const ws = response.webSocket!; + + const { promise, resolve, reject } = Promise.withResolvers(); + ws.accept(); + ws.addEventListener("message", (message) => { + try { + // The server redefines state/setState and uses them to send back + // { answer: 42 }, proving that Object.defineProperty worked. + expect(JSON.parse(message.data as string)).toEqual({ answer: 42 }); + resolve(); + } catch (e) { + reject(e); + } finally { + ws.close(); + } + }); + + return promise; + }); + + it("allows state and setState to be redefined on a non-hibernating connection", async () => { + const ctx = createExecutionContext(); + const request = new Request( + "http://example.com/parties/configurable-state-in-memory/room1", + { + headers: { + Upgrade: "websocket" + } + } + ); + const response = await worker.fetch(request, env, ctx); + const ws = response.webSocket!; + + const { promise, resolve, reject } = Promise.withResolvers(); + ws.accept(); + ws.addEventListener("message", (message) => { + try { + // The non-hibernating server redefines state/setState and sends back + // { answer: 99 }, proving Object.defineProperty works on this path too. + expect(JSON.parse(message.data as string)).toEqual({ answer: 99 }); + resolve(); + } catch (e) { + reject(e); + } finally { + ws.close(); + } + }); + + return promise; + }); + + it("persists state through setState and reads it back via state getter", async () => { + const ctx = createExecutionContext(); + const request = new Request( + "http://example.com/parties/state-round-trip/room1", + { + headers: { Upgrade: "websocket" } + } + ); + const response = await worker.fetch(request, env, ctx); + const ws = response.webSocket!; + ws.accept(); + + // Collect all messages to verify the full round-trip + const messages: unknown[] = []; + const { promise, resolve, reject } = Promise.withResolvers(); + + ws.addEventListener("message", (event) => { + try { + messages.push(JSON.parse(event.data as string)); + + if (messages.length === 1) { + // First response: "get" should return the initial state set in onConnect + expect(messages[0]).toEqual({ count: 1 }); + // Now ask the server to increment using the updater function form + ws.send("increment"); + } else if (messages.length === 2) { + // Second response: state should reflect the increment + expect(messages[1]).toEqual({ count: 2 }); + resolve(); + } + } catch (e) { + reject(e); + } + }); + + // Ask the server to read back the state that was set in onConnect + ws.send("get"); + + await promise; + ws.close(); + }); + // it("can be connected with a query parameter"); // it("can be connected with a header"); diff --git a/packages/partyserver/src/tests/worker.ts b/packages/partyserver/src/tests/worker.ts index d0c12b4..def870d 100644 --- a/packages/partyserver/src/tests/worker.ts +++ b/packages/partyserver/src/tests/worker.ts @@ -12,6 +12,9 @@ export type Env = { Stateful: DurableObjectNamespace; OnStartServer: DurableObjectNamespace; Mixed: DurableObjectNamespace; + ConfigurableState: DurableObjectNamespace; + ConfigurableStateInMemory: DurableObjectNamespace; + StateRoundTrip: DurableObjectNamespace; }; export class Stateful extends Server { @@ -93,6 +96,99 @@ export class Mixed extends Server { } } +/** + * Tests that state and setState on a connection can be redefined via + * Object.defineProperty (configurable: true). This simulates what the + * Cloudflare Agents SDK does to namespace internal state keys. + */ +export class ConfigurableState extends Server { + static options = { + hibernate: true + }; + + onConnect(connection: Connection): void { + // Redefine state and setState with a custom namespace, + // similar to what the Agents SDK does. + let _customState: unknown = { custom: true }; + + Object.defineProperty(connection, "state", { + configurable: true, + get() { + return _customState; + } + }); + + Object.defineProperty(connection, "setState", { + configurable: true, + value(newState: unknown) { + _customState = newState; + return _customState; + } + }); + + // Use the redefined setState / state to verify they work + connection.setState({ answer: 42 }); + connection.send(JSON.stringify(connection.state)); + } +} + +/** + * Tests that setState persists state and the state getter reads it back + * correctly through the serialization layer (hibernating path). + */ +export class StateRoundTrip extends Server { + static options = { + hibernate: true + }; + + onConnect(connection: Connection): void { + connection.setState({ count: 1 }); + } + + onMessage(connection: Connection, message: string | ArrayBuffer): void { + if (message === "get") { + connection.send(JSON.stringify(connection.state)); + } else if (message === "increment") { + connection.setState((prev: { count: number } | null) => ({ + count: (prev?.count ?? 0) + 1 + })); + connection.send(JSON.stringify(connection.state)); + } + } +} + +/** + * Same as ConfigurableState but without hibernation (non-hibernating path). + * Verifies that the Object.assign path also allows redefinition. + */ +export class ConfigurableStateInMemory extends Server { + // no hibernate — uses the in-memory Object.assign path + onConnect(connection: Connection): void { + let _customState: unknown = { custom: true }; + + Object.defineProperty(connection, "state", { + configurable: true, + get() { + return _customState; + }, + set(v: unknown) { + _customState = v; + } + }); + + Object.defineProperty(connection, "setState", { + configurable: true, + value(newState: unknown) { + _customState = newState; + return _customState; + } + }); + + connection.setState({ answer: 99 }); + connection.send(JSON.stringify(connection.state)); + } +} + export default { async fetch(request: Request, env: Env, _ctx: ExecutionContext) { return ( diff --git a/packages/partyserver/src/tests/wrangler.jsonc b/packages/partyserver/src/tests/wrangler.jsonc index 3ed9315..fe591aa 100644 --- a/packages/partyserver/src/tests/wrangler.jsonc +++ b/packages/partyserver/src/tests/wrangler.jsonc @@ -22,6 +22,18 @@ { "name": "Mixed", "class_name": "Mixed" + }, + { + "name": "ConfigurableState", + "class_name": "ConfigurableState" + }, + { + "name": "ConfigurableStateInMemory", + "class_name": "ConfigurableStateInMemory" + }, + { + "name": "StateRoundTrip", + "class_name": "StateRoundTrip" } ] }, @@ -29,6 +41,14 @@ { "tag": "v1", // Should be unique for each entry "new_classes": ["Stateful", "OnStartServer", "Mixed"] + }, + { + "tag": "v2", + "new_classes": [ + "ConfigurableState", + "ConfigurableStateInMemory", + "StateRoundTrip" + ] } ] } diff --git a/packages/partyserver/src/types.ts b/packages/partyserver/src/types.ts index 536c28d..2a00507 100644 --- a/packages/partyserver/src/types.ts +++ b/packages/partyserver/src/types.ts @@ -28,18 +28,46 @@ export type Connection = WebSocket & { /** * Arbitrary state associated with this connection. - * Read-only, use Connection.setState to update the state. + * Read-only — use {@link Connection.setState} to update. + * + * This property is configurable, meaning it can be redefined via + * `Object.defineProperty` by downstream consumers (e.g. the Cloudflare + * Agents SDK) to namespace or wrap internal state storage. */ state: ConnectionState; + /** + * Update the state associated with this connection. + * + * Accepts either a new state value or an updater function that receives + * the previous state and returns the next state. + * + * This property is configurable, meaning it can be redefined via + * `Object.defineProperty` by downstream consumers. If you redefine + * `state` and `setState`, you are responsible for calling + * `serializeAttachment` / `deserializeAttachment` yourself if you need + * the state to survive hibernation. + */ setState( state: TState | ConnectionSetStateFn | null ): ConnectionState; - /** @deprecated use Connection.setState instead */ + /** + * @deprecated use {@link Connection.setState} instead. + * + * Low-level method to persist data in the connection's attachment storage. + * This property is configurable and can be redefined by downstream + * consumers that need to wrap or namespace the underlying storage. + */ serializeAttachment(attachment: T): void; - /** @deprecated use Connection.state instead */ + /** + * @deprecated use {@link Connection.state} instead. + * + * Low-level method to read data from the connection's attachment storage. + * This property is configurable and can be redefined by downstream + * consumers that need to wrap or namespace the underlying storage. + */ deserializeAttachment(): T | null; /** diff --git a/packages/partysocket/src/index.ts b/packages/partysocket/src/index.ts index 66310e2..5c62716 100644 --- a/packages/partysocket/src/index.ts +++ b/packages/partysocket/src/index.ts @@ -150,6 +150,13 @@ export default class PartySocket extends ReconnectingWebSocket { this.setWSProperties(wsOptions); + if (!partySocketOptions.startClosed && !this.room && !this.basePath) { + this.close(); + throw new Error( + "Either room or basePath must be provided to connect. Use startClosed: true to create a socket and set them via updateProperties before calling reconnect()." + ); + } + if (!partySocketOptions.disableNameValidation) { if (partySocketOptions.party?.includes("/")) { console.warn( diff --git a/packages/partysocket/src/tests/edge-cases.test.ts b/packages/partysocket/src/tests/edge-cases.test.ts index 1ed732f..4a71c63 100644 --- a/packages/partysocket/src/tests/edge-cases.test.ts +++ b/packages/partysocket/src/tests/edge-cases.test.ts @@ -10,77 +10,80 @@ import ReconnectingWebSocket from "../ws"; const PORT = 50136; -describe.skip("Edge Cases - UUID Generation", () => { - it("should use crypto.randomUUID when available", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - UUID Generation", + () => { + it("should use crypto.randomUUID when available", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); + + // UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + expect(ps.id).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/ + ); }); - // UUID format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx - expect(ps.id).toMatch( - /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/ - ); - }); + it("should generate valid UUID when crypto.randomUUID is not available", () => { + // Save original + const originalRandomUUID = global.crypto?.randomUUID; - it("should generate valid UUID when crypto.randomUUID is not available", () => { - // Save original - const originalRandomUUID = global.crypto?.randomUUID; + // Remove randomUUID temporarily + if (global.crypto) { + // @ts-expect-error - testing fallback + delete global.crypto.randomUUID; + } - // Remove randomUUID temporarily - if (global.crypto) { - // @ts-expect-error - testing fallback - delete global.crypto.randomUUID; - } + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true - }); + // Should still generate a valid UUID + expect(ps.id).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ + ); - // Should still generate a valid UUID - expect(ps.id).toMatch( - /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ - ); + // Restore + if (global.crypto && originalRandomUUID) { + global.crypto.randomUUID = originalRandomUUID; + } + }); - // Restore - if (global.crypto && originalRandomUUID) { - global.crypto.randomUUID = originalRandomUUID; - } - }); + it("should generate different UUIDs for different sockets", () => { + const ps1 = new PartySocket({ + host: `localhost:${PORT}`, + room: "room1", + startClosed: true + }); - it("should generate different UUIDs for different sockets", () => { - const ps1 = new PartySocket({ - host: `localhost:${PORT}`, - room: "room1", - startClosed: true - }); + const ps2 = new PartySocket({ + host: `localhost:${PORT}`, + room: "room2", + startClosed: true + }); - const ps2 = new PartySocket({ - host: `localhost:${PORT}`, - room: "room2", - startClosed: true + expect(ps1.id).not.toBe(ps2.id); }); - expect(ps1.id).not.toBe(ps2.id); - }); + it("should use provided id when specified", () => { + const customId = "my-custom-client-id"; + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + id: customId, + startClosed: true + }); - it("should use provided id when specified", () => { - const customId = "my-custom-client-id"; - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - id: customId, - startClosed: true + expect(ps.id).toBe(customId); }); + } +); - expect(ps.id).toBe(customId); - }); -}); - -describe.skip("Edge Cases - BinaryType", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("Edge Cases - BinaryType", () => { let wss: WebSocketServer; beforeEach(() => { @@ -169,377 +172,389 @@ describe.skip("Edge Cases - BinaryType", () => { }); }); -describe.skip("Edge Cases - IP Address Detection", () => { - it("should detect localhost with 127.0.0.1", () => { - const ps = new PartySocket({ - host: "127.0.0.1:1999", - room: "test-room", - startClosed: true - }); - - expect(ps.roomUrl).toContain("ws://"); - }); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - IP Address Detection", + () => { + it("should detect localhost with 127.0.0.1", () => { + const ps = new PartySocket({ + host: "127.0.0.1:1999", + room: "test-room", + startClosed: true + }); - it("should detect localhost with localhost", () => { - const ps = new PartySocket({ - host: "localhost:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect localhost with localhost", () => { + const ps = new PartySocket({ + host: "localhost:1999", + room: "test-room", + startClosed: true + }); - it("should detect private IP 192.168.x.x", () => { - const ps = new PartySocket({ - host: "192.168.1.100:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect private IP 192.168.x.x", () => { + const ps = new PartySocket({ + host: "192.168.1.100:1999", + room: "test-room", + startClosed: true + }); - it("should detect private IP 10.x.x.x", () => { - const ps = new PartySocket({ - host: "10.0.0.1:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect private IP 10.x.x.x", () => { + const ps = new PartySocket({ + host: "10.0.0.1:1999", + room: "test-room", + startClosed: true + }); - it("should detect private IP 172.16.x.x (lower bound)", () => { - const ps = new PartySocket({ - host: "172.16.0.1:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect private IP 172.16.x.x (lower bound)", () => { + const ps = new PartySocket({ + host: "172.16.0.1:1999", + room: "test-room", + startClosed: true + }); - it("should detect private IP 172.31.x.x (upper bound)", () => { - const ps = new PartySocket({ - host: "172.31.255.255:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect private IP 172.31.x.x (upper bound)", () => { + const ps = new PartySocket({ + host: "172.31.255.255:1999", + room: "test-room", + startClosed: true + }); - it("should detect private IP 172.20.x.x (middle of range)", () => { - const ps = new PartySocket({ - host: "172.20.10.5:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect private IP 172.20.x.x (middle of range)", () => { + const ps = new PartySocket({ + host: "172.20.10.5:1999", + room: "test-room", + startClosed: true + }); - it("should NOT detect 172.15.x.x as private (below range)", () => { - const ps = new PartySocket({ - host: "172.15.0.1:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("wss://"); - }); + it("should NOT detect 172.15.x.x as private (below range)", () => { + const ps = new PartySocket({ + host: "172.15.0.1:1999", + room: "test-room", + startClosed: true + }); - it("should NOT detect 172.32.x.x as private (above range)", () => { - const ps = new PartySocket({ - host: "172.32.0.1:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("wss://"); }); - expect(ps.roomUrl).toContain("wss://"); - }); + it("should NOT detect 172.32.x.x as private (above range)", () => { + const ps = new PartySocket({ + host: "172.32.0.1:1999", + room: "test-room", + startClosed: true + }); - it("should detect IPv6 localhost [::ffff:7f00:1]", () => { - const ps = new PartySocket({ - host: "[::ffff:7f00:1]:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("wss://"); }); - expect(ps.roomUrl).toContain("ws://"); - }); + it("should detect IPv6 localhost [::ffff:7f00:1]", () => { + const ps = new PartySocket({ + host: "[::ffff:7f00:1]:1999", + room: "test-room", + startClosed: true + }); - it("should use wss for public domain", () => { - const ps = new PartySocket({ - host: "example.com", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("ws://"); }); - expect(ps.roomUrl).toContain("wss://"); - }); + it("should use wss for public domain", () => { + const ps = new PartySocket({ + host: "example.com", + room: "test-room", + startClosed: true + }); - it("should use wss for public IP", () => { - const ps = new PartySocket({ - host: "8.8.8.8:1999", - room: "test-room", - startClosed: true + expect(ps.roomUrl).toContain("wss://"); }); - expect(ps.roomUrl).toContain("wss://"); - }); -}); + it("should use wss for public IP", () => { + const ps = new PartySocket({ + host: "8.8.8.8:1999", + room: "test-room", + startClosed: true + }); -describe.skip("Edge Cases - Message Queue", () => { - let wss: WebSocketServer; + expect(ps.roomUrl).toContain("wss://"); + }); + } +); - beforeEach(() => { - wss = new WebSocketServer({ port: PORT }); - }); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - Message Queue", + () => { + let wss: WebSocketServer; - afterEach(() => { - wss.close(); - }); + beforeEach(() => { + wss = new WebSocketServer({ port: PORT }); + }); - it("should queue messages up to maxEnqueuedMessages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - maxEnqueuedMessages: 3, - startClosed: true + afterEach(() => { + wss.close(); }); - ps.send("message1"); - ps.send("message2"); - ps.send("message3"); + it("should queue messages up to maxEnqueuedMessages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + maxEnqueuedMessages: 3, + startClosed: true + }); - expect(ps.bufferedAmount).toBeGreaterThan(0); - }); + ps.send("message1"); + ps.send("message2"); + ps.send("message3"); - it("should not queue messages beyond maxEnqueuedMessages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - maxEnqueuedMessages: 2, - startClosed: true + expect(ps.bufferedAmount).toBeGreaterThan(0); }); - ps.send("message1"); - ps.send("message2"); - const bufferedBefore = ps.bufferedAmount; + it("should not queue messages beyond maxEnqueuedMessages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + maxEnqueuedMessages: 2, + startClosed: true + }); - ps.send("message3"); // This should be dropped + ps.send("message1"); + ps.send("message2"); + const bufferedBefore = ps.bufferedAmount; - expect(ps.bufferedAmount).toBe(bufferedBefore); - }); + ps.send("message3"); // This should be dropped - it("should handle exactly maxEnqueuedMessages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - maxEnqueuedMessages: 5, - startClosed: true + expect(ps.bufferedAmount).toBe(bufferedBefore); }); - // Send exactly 5 messages - for (let i = 0; i < 5; i++) { - ps.send(`message${i}`); - } + it("should handle exactly maxEnqueuedMessages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + maxEnqueuedMessages: 5, + startClosed: true + }); - expect(ps.bufferedAmount).toBeGreaterThan(0); + // Send exactly 5 messages + for (let i = 0; i < 5; i++) { + ps.send(`message${i}`); + } - // 6th message should be dropped - const bufferedBefore = ps.bufferedAmount; - ps.send("message6"); - expect(ps.bufferedAmount).toBe(bufferedBefore); - }); + expect(ps.bufferedAmount).toBeGreaterThan(0); - it("should calculate bufferedAmount for string messages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + // 6th message should be dropped + const bufferedBefore = ps.bufferedAmount; + ps.send("message6"); + expect(ps.bufferedAmount).toBe(bufferedBefore); }); - ps.send("hello"); - expect(ps.bufferedAmount).toBe(5); // "hello".length - }); + it("should calculate bufferedAmount for string messages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - it("should calculate bufferedAmount for Blob messages", async () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + ps.send("hello"); + expect(ps.bufferedAmount).toBe(5); // "hello".length }); - const blob = new Blob(["test data"]); - ps.send(blob); + it("should calculate bufferedAmount for Blob messages", async () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - expect(ps.bufferedAmount).toBe(9); // blob.size - }); + const blob = new Blob(["test data"]); + ps.send(blob); - it("should calculate bufferedAmount for ArrayBuffer messages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(ps.bufferedAmount).toBe(9); // blob.size }); - const buffer = new ArrayBuffer(8); - ps.send(buffer); + it("should calculate bufferedAmount for ArrayBuffer messages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - expect(ps.bufferedAmount).toBe(8); // buffer.byteLength - }); + const buffer = new ArrayBuffer(8); + ps.send(buffer); - it("should calculate bufferedAmount for ArrayBufferView messages", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(ps.bufferedAmount).toBe(8); // buffer.byteLength }); - const view = new Uint8Array([1, 2, 3, 4, 5]); - ps.send(view); + it("should calculate bufferedAmount for ArrayBufferView messages", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - expect(ps.bufferedAmount).toBe(5); // view.byteLength - }); + const view = new Uint8Array([1, 2, 3, 4, 5]); + ps.send(view); - it("should flush message queue on connection", async () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(ps.bufferedAmount).toBe(5); // view.byteLength }); - const messages: string[] = []; - wss.on("connection", (ws) => { - ws.on("message", (data) => { - messages.push(data.toString()); + it("should flush message queue on connection", async () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true }); - }); - // Queue messages - ps.send("queued1"); - ps.send("queued2"); - expect(ps.bufferedAmount).toBeGreaterThan(0); + const messages: string[] = []; + wss.on("connection", (ws) => { + ws.on("message", (data) => { + messages.push(data.toString()); + }); + }); - const openPromise = new Promise((resolve) => { - ps.addEventListener("open", () => resolve()); - }); + // Queue messages + ps.send("queued1"); + ps.send("queued2"); + expect(ps.bufferedAmount).toBeGreaterThan(0); - ps.reconnect(); - await openPromise; + const openPromise = new Promise((resolve) => { + ps.addEventListener("open", () => resolve()); + }); - // Wait for messages to be sent - await new Promise((resolve) => setTimeout(resolve, 100)); + ps.reconnect(); + await openPromise; - expect(messages).toContain("queued1"); - expect(messages).toContain("queued2"); + // Wait for messages to be sent + await new Promise((resolve) => setTimeout(resolve, 100)); - ps.close(); - }); -}); + expect(messages).toContain("queued1"); + expect(messages).toContain("queued2"); -describe.skip("Edge Cases - ReadyState Constants", () => { - it("should expose static readyState constants", () => { - expect(ReconnectingWebSocket.CONNECTING).toBe(0); - expect(ReconnectingWebSocket.OPEN).toBe(1); - expect(ReconnectingWebSocket.CLOSING).toBe(2); - expect(ReconnectingWebSocket.CLOSED).toBe(3); - }); + ps.close(); + }); + } +); - it("should expose instance readyState constants", () => { - const ps = new PartySocket({ - host: "localhost:1999", - room: "test-room", - startClosed: true +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - ReadyState Constants", + () => { + it("should expose static readyState constants", () => { + expect(ReconnectingWebSocket.CONNECTING).toBe(0); + expect(ReconnectingWebSocket.OPEN).toBe(1); + expect(ReconnectingWebSocket.CLOSING).toBe(2); + expect(ReconnectingWebSocket.CLOSED).toBe(3); }); - expect(ps.CONNECTING).toBe(0); - expect(ps.OPEN).toBe(1); - expect(ps.CLOSING).toBe(2); - expect(ps.CLOSED).toBe(3); - }); + it("should expose instance readyState constants", () => { + const ps = new PartySocket({ + host: "localhost:1999", + room: "test-room", + startClosed: true + }); - it("should report CLOSED state when startClosed is true", () => { - const ps = new PartySocket({ - host: "localhost:1999", - room: "test-room", - startClosed: true + expect(ps.CONNECTING).toBe(0); + expect(ps.OPEN).toBe(1); + expect(ps.CLOSING).toBe(2); + expect(ps.CLOSED).toBe(3); }); - expect(ps.readyState).toBe(ReconnectingWebSocket.CLOSED); - }); + it("should report CLOSED state when startClosed is true", () => { + const ps = new PartySocket({ + host: "localhost:1999", + room: "test-room", + startClosed: true + }); - it("should report CONNECTING state by default", () => { - const ps = new PartySocket({ - host: "localhost:1999", - room: "test-room" + expect(ps.readyState).toBe(ReconnectingWebSocket.CLOSED); }); - expect(ps.readyState).toBe(ReconnectingWebSocket.CONNECTING); - ps.close(); - }); -}); + it("should report CONNECTING state by default", () => { + const ps = new PartySocket({ + host: "localhost:1999", + room: "test-room" + }); -describe.skip("Edge Cases - Close Behavior", () => { - let wss: WebSocketServer; + expect(ps.readyState).toBe(ReconnectingWebSocket.CONNECTING); + ps.close(); + }); + } +); - beforeEach(() => { - wss = new WebSocketServer({ port: PORT }); - }); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - Close Behavior", + () => { + let wss: WebSocketServer; - afterEach(() => { - wss.close(); - }); + beforeEach(() => { + wss = new WebSocketServer({ port: PORT }); + }); - it("should handle close() when not connected", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + afterEach(() => { + wss.close(); }); - expect(() => ps.close()).not.toThrow(); - }); + it("should handle close() when not connected", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - it("should handle multiple close() calls", async () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(() => ps.close()).not.toThrow(); }); - const openPromise = new Promise((resolve) => { - ps.addEventListener("open", () => resolve()); - }); + it("should handle multiple close() calls", async () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - ps.reconnect(); - await openPromise; + const openPromise = new Promise((resolve) => { + ps.addEventListener("open", () => resolve()); + }); - ps.close(); - expect(() => ps.close()).not.toThrow(); - }); + ps.reconnect(); + await openPromise; - it("should set shouldReconnect to false when close() is called", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + ps.close(); + expect(() => ps.close()).not.toThrow(); }); - expect(ps.shouldReconnect).toBe(false); + it("should set shouldReconnect to false when close() is called", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - ps.reconnect(); - expect(ps.shouldReconnect).toBe(true); + expect(ps.shouldReconnect).toBe(false); - ps.close(); - expect(ps.shouldReconnect).toBe(false); - }); -}); + ps.reconnect(); + expect(ps.shouldReconnect).toBe(true); -describe.skip("Edge Cases - RetryCount", () => { + ps.close(); + expect(ps.shouldReconnect).toBe(false); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)("Edge Cases - RetryCount", () => { it("should start with retryCount 0", () => { const ps = new PartySocket({ host: "localhost:1999", @@ -577,44 +592,47 @@ describe.skip("Edge Cases - RetryCount", () => { }); }); -describe.skip("Edge Cases - Extensions and Protocol", () => { - let wss: WebSocketServer; - - beforeEach(() => { - wss = new WebSocketServer({ port: PORT }); - }); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Edge Cases - Extensions and Protocol", + () => { + let wss: WebSocketServer; - afterEach(() => { - wss.close(); - }); + beforeEach(() => { + wss = new WebSocketServer({ port: PORT }); + }); - it("should return empty string for extensions when not connected", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + afterEach(() => { + wss.close(); }); - expect(ps.extensions).toBe(""); - }); + it("should return empty string for extensions when not connected", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - it("should return empty string for protocol when not connected", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(ps.extensions).toBe(""); }); - expect(ps.protocol).toBe(""); - }); + it("should return empty string for protocol when not connected", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); - it("should return empty string for url when not connected", () => { - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "test-room", - startClosed: true + expect(ps.protocol).toBe(""); }); - expect(ps.url).toBe(""); - }); -}); + it("should return empty string for url when not connected", () => { + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "test-room", + startClosed: true + }); + + expect(ps.url).toBe(""); + }); + } +); diff --git a/packages/partysocket/src/tests/error-handling.test.ts b/packages/partysocket/src/tests/error-handling.test.ts index 985a185..961f53f 100644 --- a/packages/partysocket/src/tests/error-handling.test.ts +++ b/packages/partysocket/src/tests/error-handling.test.ts @@ -7,552 +7,603 @@ import { afterEach, beforeEach, describe, expect, test, vitest } from "vitest"; import PartySocket from "../index"; import ReconnectingWebSocket from "../ws"; -describe.skip("Error Handling - URL Providers", () => { - test("handles async URL provider that throws", async () => { - const errorSpy = vitest.fn(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - URL Providers", + () => { + test("handles async URL provider that throws", async () => { + const errorSpy = vitest.fn(); + + const ws = new ReconnectingWebSocket( + async () => { + throw new Error("URL fetch failed"); + }, + undefined, + { maxRetries: 0 } + ); - const ws = new ReconnectingWebSocket( - async () => { - throw new Error("URL fetch failed"); - }, - undefined, - { maxRetries: 0 } - ); + ws.addEventListener("error", (event) => { + errorSpy(event); + }); - ws.addEventListener("error", (event) => { - errorSpy(event); + await new Promise((resolve) => { + setTimeout(() => { + expect(errorSpy).toHaveBeenCalled(); + ws.close(); + resolve(); + }, 100); + }); }); - await new Promise((resolve) => { + test("handles sync URL provider that throws", () => { + const errorSpy = vitest.fn(); + + const ws = new ReconnectingWebSocket( + () => { + throw new Error("URL generation failed"); + }, + undefined, + { maxRetries: 0 } + ); + + ws.addEventListener("error", (event) => { + errorSpy(event); + }); + setTimeout(() => { expect(errorSpy).toHaveBeenCalled(); ws.close(); - resolve(); }, 100); }); - }); - test("handles sync URL provider that throws", () => { - const errorSpy = vitest.fn(); + test("handles invalid URL provider type", async () => { + const ws = new ReconnectingWebSocket( + // @ts-expect-error - testing invalid type + 123, + undefined, + { maxRetries: 0 } + ); - const ws = new ReconnectingWebSocket( - () => { - throw new Error("URL generation failed"); - }, - undefined, - { maxRetries: 0 } - ); + // The error happens when trying to get the URL + await expect(async () => { + // @ts-expect-error - accessing private method for testing + await ws._getNextUrl(123); + }).rejects.toThrow(); - ws.addEventListener("error", (event) => { - errorSpy(event); + ws.close(); }); + } +); - setTimeout(() => { - expect(errorSpy).toHaveBeenCalled(); - ws.close(); - }, 100); - }); - - test("handles invalid URL provider type", async () => { - const ws = new ReconnectingWebSocket( - // @ts-expect-error - testing invalid type - 123, - undefined, - { maxRetries: 0 } - ); - - // The error happens when trying to get the URL - await expect(async () => { - // @ts-expect-error - accessing private method for testing - await ws._getNextUrl(123); - }).rejects.toThrow(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Protocol Providers", + () => { + test("handles invalid protocol provider", async () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + maxRetries: 0, + startClosed: true + }); - ws.close(); - }); -}); + // The method throws synchronously for invalid input + try { + // @ts-expect-error - accessing private method for testing + await ws._getNextProtocols(() => /regex/); + expect.fail("Should have thrown an error"); + } catch (error) { + expect((error as Error).message).toContain("Invalid protocols"); + } -describe.skip("Error Handling - Protocol Providers", () => { - test("handles invalid protocol provider", async () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - maxRetries: 0, - startClosed: true + ws.close(); }); - // The method throws synchronously for invalid input - try { - // @ts-expect-error - accessing private method for testing - await ws._getNextProtocols(() => /regex/); - expect.fail("Should have thrown an error"); - } catch (error) { - expect((error as Error).message).toContain("Invalid protocols"); - } + test("handles null protocol provider", async () => { + const ws = new ReconnectingWebSocket("ws://example.com", null, { + maxRetries: 0, + startClosed: true + }); - ws.close(); - }); + // @ts-expect-error - accessing private method for testing + const result = await ws._getNextProtocols(null); + expect(result).toBeNull(); - test("handles null protocol provider", async () => { - const ws = new ReconnectingWebSocket("ws://example.com", null, { - maxRetries: 0, - startClosed: true + ws.close(); }); + } +); - // @ts-expect-error - accessing private method for testing - const result = await ws._getNextProtocols(null); - expect(result).toBeNull(); - - ws.close(); - }); -}); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - PartySocket Validation", + () => { + test("throws when path starts with slash", () => { + expect(() => { + new PartySocket({ + host: "example.com", + room: "my-room", + path: "/invalid-path" + }); + }).toThrow("path must not start with a slash"); + }); -describe.skip("Error Handling - PartySocket Validation", () => { - test("throws when path starts with slash", () => { - expect(() => { - new PartySocket({ + test("throws when reconnecting without host", () => { + const ps = new PartySocket({ host: "example.com", room: "my-room", - path: "/invalid-path" + startClosed: true }); - }).toThrow("path must not start with a slash"); - }); - - test("throws when reconnecting without host", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true - }); - - ps.updateProperties({ host: "" }); - expect(() => { - ps.reconnect(); - }).toThrow("The host must be set"); - }); + ps.updateProperties({ host: "" }); - test("throws when reconnecting without room", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true + expect(() => { + ps.reconnect(); + }).toThrow("The host must be set"); }); - ps.updateProperties({ room: "" }); + test("throws when reconnecting without room", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); - expect(() => { - ps.reconnect(); - }).toThrow("The room (or basePath) must be set"); - }); + ps.updateProperties({ room: "" }); - test("does not throw when reconnecting with basePath and no room", () => { - const ps = new PartySocket({ - host: "example.com", - basePath: "custom/path", - startClosed: true + expect(() => { + ps.reconnect(); + }).toThrow("The room (or basePath) must be set"); }); - expect(() => { - ps.reconnect(); - }).not.toThrow(); - }); - - test("handles missing WebSocket constructor gracefully", async () => { - const originalWS = (global as unknown as { WebSocket?: unknown }).WebSocket; - delete (global as unknown as { WebSocket?: unknown }).WebSocket; - - const errorSpy = vitest - .spyOn(console, "error") - .mockImplementation(() => {}); + test("does not throw when reconnecting with basePath and no room", () => { + const ps = new PartySocket({ + host: "example.com", + basePath: "custom/path", + startClosed: true + }); - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - maxRetries: 0, - startClosed: false // Need to try to connect to trigger error + expect(() => { + ps.reconnect(); + }).not.toThrow(); }); - // Wait a bit for the error to be logged - await new Promise((resolve) => { - setTimeout(() => { - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("No WebSocket implementation") - ); - ws.close(); - (global as unknown as { WebSocket: unknown }).WebSocket = originalWS; - errorSpy.mockRestore(); - resolve(); - }, 100); + test("throws when constructing without room or basePath and not startClosed", () => { + expect(() => { + new PartySocket({ + host: "example.com" + }); + }).toThrow("Either room or basePath must be provided"); }); - }); -}); -describe.skip("Error Handling - Connection Failures", () => { - test("handles immediate connection failure", async () => { - const errorSpy = vitest.fn(); - - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 1, - maxReconnectionDelay: 100 + test("does not throw when constructing without room or basePath with startClosed", () => { + expect(() => { + new PartySocket({ + host: "example.com", + startClosed: true + }); + }).not.toThrow(); }); - ws.addEventListener("error", errorSpy); + test("handles missing WebSocket constructor gracefully", async () => { + const originalWS = (global as unknown as { WebSocket?: unknown }) + .WebSocket; + delete (global as unknown as { WebSocket?: unknown }).WebSocket; - await new Promise((resolve) => { - setTimeout(() => { - expect(errorSpy).toHaveBeenCalled(); - expect(ws.retryCount).toBeGreaterThan(0); - ws.close(); - resolve(); - }, 500); - }); - }); + const errorSpy = vitest + .spyOn(console, "error") + .mockImplementation(() => {}); - test("stops retrying after maxRetries", async () => { - const maxRetries = 3; - let errorCount = 0; + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + maxRetries: 0, + startClosed: false // Need to try to connect to trigger error + }); - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries, - minReconnectionDelay: 10, - maxReconnectionDelay: 20 + // Wait a bit for the error to be logged + await new Promise((resolve) => { + setTimeout(() => { + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("No WebSocket implementation") + ); + ws.close(); + (global as unknown as { WebSocket: unknown }).WebSocket = originalWS; + errorSpy.mockRestore(); + resolve(); + }, 100); + }); }); + } +); - ws.addEventListener("error", () => { - errorCount++; - }); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Connection Failures", + () => { + test("handles immediate connection failure", async () => { + const errorSpy = vitest.fn(); - await new Promise((resolve) => { - setTimeout(() => { - expect(ws.retryCount).toBe(maxRetries); - expect(errorCount).toBeGreaterThanOrEqual(maxRetries); - ws.close(); - resolve(); - }, 500); - }); - }); + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 1, + maxReconnectionDelay: 100 + }); - test("handles connection timeout", async () => { - const timeoutSpy = vitest.fn(); + ws.addEventListener("error", errorSpy); - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - connectionTimeout: 100, - maxRetries: 1 + await new Promise((resolve) => { + setTimeout(() => { + expect(errorSpy).toHaveBeenCalled(); + expect(ws.retryCount).toBeGreaterThan(0); + ws.close(); + resolve(); + }, 500); + }); }); - ws.addEventListener("error", (event) => { - if ((event as { message?: string }).message === "TIMEOUT") { - timeoutSpy(); - } - }); + test("stops retrying after maxRetries", async () => { + const maxRetries = 3; + let errorCount = 0; - await new Promise((resolve) => { - setTimeout(() => { - ws.close(); - resolve(); - }, 500); - }); - }); -}); + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries, + minReconnectionDelay: 10, + maxReconnectionDelay: 20 + }); + + ws.addEventListener("error", () => { + errorCount++; + }); -describe.skip("Error Handling - Message Queue", () => { - test("respects maxEnqueuedMessages limit", () => { - const maxMessages = 5; - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 0, - maxEnqueuedMessages: maxMessages + await new Promise((resolve) => { + setTimeout(() => { + expect(ws.retryCount).toBe(maxRetries); + expect(errorCount).toBeGreaterThanOrEqual(maxRetries); + ws.close(); + resolve(); + }, 500); + }); }); - // Try to send more messages than the limit - for (let i = 0; i < maxMessages + 10; i++) { - ws.send(`message-${i}`); - } + test("handles connection timeout", async () => { + const timeoutSpy = vitest.fn(); - // Should only have maxMessages in queue - expect(ws.bufferedAmount).toBeLessThanOrEqual(maxMessages * 10); + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + connectionTimeout: 100, + maxRetries: 1 + }); - ws.close(); - }); + ws.addEventListener("error", (event) => { + if ((event as { message?: string }).message === "TIMEOUT") { + timeoutSpy(); + } + }); - test("calculates buffered amount for different message types", () => { - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 0, - startClosed: true + await new Promise((resolve) => { + setTimeout(() => { + ws.close(); + resolve(); + }, 500); + }); }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Message Queue", + () => { + test("respects maxEnqueuedMessages limit", () => { + const maxMessages = 5; + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 0, + maxEnqueuedMessages: maxMessages + }); - const stringMsg = "hello"; - const arrayBuffer = new ArrayBuffer(10); - const blob = new Blob(["test"]); + // Try to send more messages than the limit + for (let i = 0; i < maxMessages + 10; i++) { + ws.send(`message-${i}`); + } - ws.send(stringMsg); - ws.send(arrayBuffer); - ws.send(blob); + // Should only have maxMessages in queue + expect(ws.bufferedAmount).toBeLessThanOrEqual(maxMessages * 10); - expect(ws.bufferedAmount).toBeGreaterThan(0); + ws.close(); + }); - ws.close(); - }); -}); + test("calculates buffered amount for different message types", () => { + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 0, + startClosed: true + }); -describe.skip("Error Handling - Event Target Polyfill", () => { - let originalEventTarget: typeof EventTarget | undefined; - let originalEvent: typeof Event | undefined; - let errorSpy: ReturnType; + const stringMsg = "hello"; + const arrayBuffer = new ArrayBuffer(10); + const blob = new Blob(["test"]); - beforeEach(() => { - errorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}); - }); + ws.send(stringMsg); + ws.send(arrayBuffer); + ws.send(blob); - afterEach(() => { - errorSpy.mockRestore(); - if (originalEventTarget) { - globalThis.EventTarget = originalEventTarget; - } - if (originalEvent) { - globalThis.Event = originalEvent; - } - }); + expect(ws.bufferedAmount).toBeGreaterThan(0); - test("warns when EventTarget is not available", async () => { - // Save original - originalEventTarget = globalThis.EventTarget; - originalEvent = globalThis.Event; + ws.close(); + }); + } +); - // Remove EventTarget - delete (globalThis as { EventTarget?: typeof EventTarget }).EventTarget; - delete (globalThis as { Event?: typeof Event }).Event; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Event Target Polyfill", + () => { + let originalEventTarget: typeof EventTarget | undefined; + let originalEvent: typeof Event | undefined; + let errorSpy: ReturnType; - // Re-import to trigger the check - // This will log an error message about missing EventTarget - await import("../ws"); + beforeEach(() => { + errorSpy = vitest.spyOn(console, "error").mockImplementation(() => {}); + }); - // Restore - globalThis.EventTarget = originalEventTarget; - globalThis.Event = originalEvent; + afterEach(() => { + errorSpy.mockRestore(); + if (originalEventTarget) { + globalThis.EventTarget = originalEventTarget; + } + if (originalEvent) { + globalThis.Event = originalEvent; + } + }); - // The error should have been logged during module load - // Note: This test may not work perfectly due to module caching - }); -}); + test("warns when EventTarget is not available", async () => { + // Save original + originalEventTarget = globalThis.EventTarget; + originalEvent = globalThis.Event; -describe.skip("Error Handling - Close Scenarios", () => { - test("handles close before connection established", () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - maxRetries: 0, - startClosed: true - }); + // Remove EventTarget + delete (globalThis as { EventTarget?: typeof EventTarget }).EventTarget; + delete (globalThis as { Event?: typeof Event }).Event; - // Close immediately - ws.close(); + // Re-import to trigger the check + // This will log an error message about missing EventTarget + await import("../ws"); - // Should be in CLOSED state (3) - expect(ws.readyState).toBe(3); - }); + // Restore + globalThis.EventTarget = originalEventTarget; + globalThis.Event = originalEvent; - test("handles multiple close calls", () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - startClosed: true + // The error should have been logged during module load + // Note: This test may not work perfectly due to module caching }); + } +); - ws.close(); - ws.close(); - ws.close(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Close Scenarios", + () => { + test("handles close before connection established", () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + maxRetries: 0, + startClosed: true + }); - expect(ws.readyState).toBe(ReconnectingWebSocket.CLOSED); - }); + // Close immediately + ws.close(); - test("handles reconnect while already connecting", () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - maxRetries: 0 + // Should be in CLOSED state (3) + expect(ws.readyState).toBe(3); }); - // Call reconnect multiple times rapidly - ws.reconnect(); - ws.reconnect(); - ws.reconnect(); + test("handles multiple close calls", () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + startClosed: true + }); - setTimeout(() => { ws.close(); - }, 100); - }); -}); - -describe.skip("Error Handling - PartySocket.fetch", () => { - test("propagates fetch errors", async () => { - const mockFetch = vitest - .fn() - .mockRejectedValue(new Error("Network failure")); - - await expect( - PartySocket.fetch({ - host: "example.com", - room: "my-room", - fetch: mockFetch - }) - ).rejects.toThrow("Network failure"); - }); - - test("handles async query provider errors in fetch", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + ws.close(); + ws.close(); - await expect( - PartySocket.fetch({ - host: "example.com", - room: "my-room", - query: async () => { - throw new Error("Query generation failed"); - }, - fetch: mockFetch - }) - ).rejects.toThrow("Query generation failed"); - }); + expect(ws.readyState).toBe(ReconnectingWebSocket.CLOSED); + }); - test("throws when path starts with slash in fetch", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + test("handles reconnect while already connecting", () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + maxRetries: 0 + }); - await expect( - PartySocket.fetch({ - host: "example.com", - room: "my-room", - path: "/invalid", - fetch: mockFetch - }) - ).rejects.toThrow("path must not start with a slash"); - }); -}); + // Call reconnect multiple times rapidly + ws.reconnect(); + ws.reconnect(); + ws.reconnect(); -describe.skip("Error Handling - Edge Cases", () => { - test("handles extremely long message queue", () => { - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 0, - maxEnqueuedMessages: Number.POSITIVE_INFINITY + setTimeout(() => { + ws.close(); + }, 100); }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - PartySocket.fetch", + () => { + test("propagates fetch errors", async () => { + const mockFetch = vitest + .fn() + .mockRejectedValue(new Error("Network failure")); + + await expect( + PartySocket.fetch({ + host: "example.com", + room: "my-room", + fetch: mockFetch + }) + ).rejects.toThrow("Network failure"); + }); + + test("handles async query provider errors in fetch", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + + await expect( + PartySocket.fetch({ + host: "example.com", + room: "my-room", + query: async () => { + throw new Error("Query generation failed"); + }, + fetch: mockFetch + }) + ).rejects.toThrow("Query generation failed"); + }); + + test("throws when path starts with slash in fetch", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + + await expect( + PartySocket.fetch({ + host: "example.com", + room: "my-room", + path: "/invalid", + fetch: mockFetch + }) + ).rejects.toThrow("path must not start with a slash"); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Edge Cases", + () => { + test("handles extremely long message queue", () => { + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 0, + maxEnqueuedMessages: Number.POSITIVE_INFINITY + }); - for (let i = 0; i < 1000; i++) { - ws.send(`message-${i}`); - } - - expect(ws.bufferedAmount).toBeGreaterThan(0); + for (let i = 0; i < 1000; i++) { + ws.send(`message-${i}`); + } - ws.close(); - }); + expect(ws.bufferedAmount).toBeGreaterThan(0); - test("handles empty message send", () => { - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 0, - startClosed: true + ws.close(); }); - expect(() => { - ws.send(""); - }).not.toThrow(); + test("handles empty message send", () => { + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 0, + startClosed: true + }); - ws.close(); - }); + expect(() => { + ws.send(""); + }).not.toThrow(); - test("handles rapid reconnect calls", () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - maxRetries: 0, - startClosed: true + ws.close(); }); - for (let i = 0; i < 10; i++) { - ws.reconnect(); - } + test("handles rapid reconnect calls", () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + maxRetries: 0, + startClosed: true + }); - ws.close(); - }); + for (let i = 0; i < 10; i++) { + ws.reconnect(); + } - test("handles binaryType changes while disconnected", () => { - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - startClosed: true + ws.close(); }); - expect(() => { - ws.binaryType = "arraybuffer"; - ws.binaryType = "blob"; - }).not.toThrow(); - - expect(ws.binaryType).toBe("blob"); + test("handles binaryType changes while disconnected", () => { + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + startClosed: true + }); - ws.close(); - }); -}); + expect(() => { + ws.binaryType = "arraybuffer"; + ws.binaryType = "blob"; + }).not.toThrow(); -describe.skip("Error Handling - Retry Logic", () => { - test("resets retry count on successful connection", async () => { - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - maxRetries: 5, - minReconnectionDelay: 10, - maxReconnectionDelay: 20, - minUptime: 50 - }); + expect(ws.binaryType).toBe("blob"); - // Wait for some retries - await new Promise((resolve) => { - setTimeout(() => { - expect(ws.retryCount).toBeGreaterThan(0); - ws.close(); - resolve(); - }, 100); + ws.close(); }); - }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Retry Logic", + () => { + test("resets retry count on successful connection", async () => { + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + maxRetries: 5, + minReconnectionDelay: 10, + maxReconnectionDelay: 20, + minUptime: 50 + }); - test("exponential backoff increases delay correctly", async () => { - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - minReconnectionDelay: 50, - maxReconnectionDelay: 500, - reconnectionDelayGrowFactor: 2, - maxRetries: 5 + // Wait for some retries + await new Promise((resolve) => { + setTimeout(() => { + expect(ws.retryCount).toBeGreaterThan(0); + ws.close(); + resolve(); + }, 100); + }); }); - const delays: number[] = []; - - for (let i = 0; i < 5; i++) { - // @ts-expect-error - accessing private method for testing - ws._retryCount = i; - // @ts-expect-error - accessing private method for testing - delays.push(ws._getNextDelay()); - } + test("exponential backoff increases delay correctly", async () => { + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + minReconnectionDelay: 50, + maxReconnectionDelay: 500, + reconnectionDelayGrowFactor: 2, + maxRetries: 5 + }); - // Verify exponential growth - expect(delays[1]).toBeGreaterThan(delays[0]); - expect(delays[2]).toBeGreaterThan(delays[1]); + const delays: number[] = []; - ws.close(); - }); -}); + for (let i = 0; i < 5; i++) { + // @ts-expect-error - accessing private method for testing + ws._retryCount = i; + // @ts-expect-error - accessing private method for testing + delays.push(ws._getNextDelay()); + } -describe.skip("Error Handling - Debug Mode", () => { - test("custom debugLogger receives messages", () => { - const debugLogger = vitest.fn(); + // Verify exponential growth + expect(delays[1]).toBeGreaterThan(delays[0]); + expect(delays[2]).toBeGreaterThan(delays[1]); - const ws = new ReconnectingWebSocket("ws://example.com", undefined, { - debug: true, - debugLogger, - maxRetries: 0, - startClosed: true + ws.close(); }); + } +); - ws.reconnect(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Error Handling - Debug Mode", + () => { + test("custom debugLogger receives messages", () => { + const debugLogger = vitest.fn(); - expect(debugLogger).toHaveBeenCalledWith( - "RWS>", - expect.any(String), - expect.anything() - ); + const ws = new ReconnectingWebSocket("ws://example.com", undefined, { + debug: true, + debugLogger, + maxRetries: 0, + startClosed: true + }); - ws.close(); - }); + ws.reconnect(); - test("debug mode logs connection attempts", () => { - const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); + expect(debugLogger).toHaveBeenCalledWith( + "RWS>", + expect.any(String), + expect.anything() + ); - const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { - debug: true, - maxRetries: 1 + ws.close(); }); - setTimeout(() => { - expect(logSpy).toHaveBeenCalled(); - ws.close(); - logSpy.mockRestore(); - }, 100); - }); -}); + test("debug mode logs connection attempts", () => { + const logSpy = vitest.spyOn(console, "log").mockImplementation(() => {}); + + const ws = new ReconnectingWebSocket("ws://255.255.255.255", undefined, { + debug: true, + maxRetries: 1 + }); + + setTimeout(() => { + expect(logSpy).toHaveBeenCalled(); + ws.close(); + logSpy.mockRestore(); + }, 100); + }); + } +); diff --git a/packages/partysocket/src/tests/integration.test.ts b/packages/partysocket/src/tests/integration.test.ts index 56030e2..5f3e830 100644 --- a/packages/partysocket/src/tests/integration.test.ts +++ b/packages/partysocket/src/tests/integration.test.ts @@ -27,596 +27,611 @@ async function getMessageText(data: unknown): Promise { return String(data); } -describe.skip("Integration - Full Lifecycle", () => { - let wss: WebSocketServer; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Integration - Full Lifecycle", + () => { + let wss: WebSocketServer; - beforeAll(() => { - wss = new WebSocketServer({ port: PORT }); - }); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT }); + }); - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); }); }); - }); - test("complete WebSocket lifecycle: connect, send, receive, close", async () => { - const testMessage = "integration-test-message"; + test("complete WebSocket lifecycle: connect, send, receive, close", async () => { + const testMessage = "integration-test-message"; - wss.once("connection", (ws) => { - ws.on("message", (data) => { - ws.send(`echo: ${data}`); + wss.once("connection", (ws) => { + ws.on("message", (data) => { + ws.send(`echo: ${data}`); + }); }); - }); - - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "lifecycle-test", - id: "test-client" - }); - await new Promise((resolve) => { - ps.addEventListener("open", () => { - expect(ps.readyState).toBe(WebSocket.OPEN); - ps.send(testMessage); + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "lifecycle-test", + id: "test-client" }); - ps.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - expect(text).toContain(testMessage); - ps.close(); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + expect(ps.readyState).toBe(WebSocket.OPEN); + ps.send(testMessage); + }); - // Wait for close event before resolving - ps.addEventListener("close", () => { - expect(ps.readyState).toBe(WebSocket.CLOSED); - resolve(); + ps.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + expect(text).toContain(testMessage); + ps.close(); + + // Wait for close event before resolving + ps.addEventListener("close", () => { + expect(ps.readyState).toBe(WebSocket.CLOSED); + resolve(); + }); }); }); }); - }); - test("handles server disconnect and reconnect", async () => { - let connectCount = 0; + test("handles server disconnect and reconnect", async () => { + let connectCount = 0; - const connectionHandler = (ws: WebSocket) => { - connectCount++; - if (connectCount === 1) { - // First connection - close after 100ms - setTimeout(() => ws.close(), 100); - } - }; - - wss.on("connection", connectionHandler); - - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "reconnect-test", - minReconnectionDelay: 50, - maxReconnectionDelay: 100 - }); + const connectionHandler = (ws: WebSocket) => { + connectCount++; + if (connectCount === 1) { + // First connection - close after 100ms + setTimeout(() => ws.close(), 100); + } + }; - await new Promise((resolve) => { - let openCount = 0; + wss.on("connection", connectionHandler); - ps.addEventListener("open", () => { - openCount++; - if (openCount === 2) { - // Successfully reconnected - expect(connectCount).toBe(2); - ps.close(); - wss.off("connection", connectionHandler); - resolve(); - } + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "reconnect-test", + minReconnectionDelay: 50, + maxReconnectionDelay: 100 }); - }); - }, 5000); - test("maintains message order during high load", async () => { - const messageCount = 20; // Reduced for test reliability - const receivedMessages: string[] = []; + await new Promise((resolve) => { + let openCount = 0; - wss.once("connection", (ws) => { - ws.on("message", (data) => { - ws.send(data); // Echo back + ps.addEventListener("open", () => { + openCount++; + if (openCount === 2) { + // Successfully reconnected + expect(connectCount).toBe(2); + ps.close(); + wss.off("connection", connectionHandler); + resolve(); + } + }); }); - }); + }, 5000); - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "order-test" - }); - - // Set up message handler before opening - ps.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - receivedMessages.push(text); - }); + test("maintains message order during high load", async () => { + const messageCount = 20; // Reduced for test reliability + const receivedMessages: string[] = []; - await new Promise((resolve) => { - ps.addEventListener("open", () => { - // Send messages sequentially - let sent = 0; - const sendNext = () => { - if (sent < messageCount) { - ps.send(`message-${sent}`); - sent++; - setTimeout(sendNext, 10); - } - }; - sendNext(); + wss.once("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); // Echo back + }); + }); - // Poll for completion - const checkInterval = setInterval(async () => { - if (receivedMessages.length >= messageCount) { - clearInterval(checkInterval); + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "order-test" + }); - // Give a moment for any pending async operations - await new Promise((r) => setTimeout(r, 100)); + // Set up message handler before opening + ps.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + receivedMessages.push(text); + }); - // Check order - at this point all messages should be strings - try { - for (let i = 0; i < messageCount; i++) { - expect(receivedMessages[i]).toBe(`message-${i}`); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + // Send messages sequentially + let sent = 0; + const sendNext = () => { + if (sent < messageCount) { + ps.send(`message-${sent}`); + sent++; + setTimeout(sendNext, 10); + } + }; + sendNext(); + + // Poll for completion + const checkInterval = setInterval(async () => { + if (receivedMessages.length >= messageCount) { + clearInterval(checkInterval); + + // Give a moment for any pending async operations + await new Promise((r) => setTimeout(r, 100)); + + // Check order - at this point all messages should be strings + try { + for (let i = 0; i < messageCount; i++) { + expect(receivedMessages[i]).toBe(`message-${i}`); + } + } catch (_e) { + // If we still have Blobs, messages aren't fully processed yet + return; } - } catch (_e) { - // If we still have Blobs, messages aren't fully processed yet - return; + + ps.close(); + resolve(); } + }, 50); + // Timeout + setTimeout(() => { + clearInterval(checkInterval); ps.close(); resolve(); - } - }, 50); - - // Timeout - setTimeout(() => { - clearInterval(checkInterval); - ps.close(); - resolve(); - }, 10000); - }); - }); - }, 15000); -}); - -describe.skip("Integration - Multiple Concurrent Connections", () => { - let wss: WebSocketServer; - - beforeAll(() => { - wss = new WebSocketServer({ port: PORT + 1 }); - }); - - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); + }, 10000); + }); }); - }); - }); + }, 15000); + } +); - test("handles multiple PartySocket instances", async () => { - const socketCount = 5; - const sockets: PartySocket[] = []; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Integration - Multiple Concurrent Connections", + () => { + let wss: WebSocketServer; - wss.on("connection", (ws) => { - ws.send("welcome"); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 1 }); }); - const promises = Array.from({ length: socketCount }, (_, i) => { + afterAll(() => { return new Promise((resolve) => { - const ps = new PartySocket({ - host: `localhost:${PORT + 1}`, - room: `room-${i}` + wss.clients.forEach((client) => { + client.terminate(); }); - - ps.addEventListener("message", () => { + wss.close(() => { resolve(); }); - - sockets.push(ps); }); }); - await Promise.all(promises); - - // All sockets should be open - for (const socket of sockets) { - expect(socket.readyState).toBe(WebSocket.OPEN); - socket.close(); - } - }); - - test("sockets with different configurations work independently", async () => { - const ps1 = new PartySocket({ - host: `localhost:${PORT + 1}`, - room: "room1", - party: "party1", - debug: true - }); + test("handles multiple PartySocket instances", async () => { + const socketCount = 5; + const sockets: PartySocket[] = []; - const ps2 = new PartySocket({ - host: `localhost:${PORT + 1}`, - room: "room2", - party: "party2", - maxRetries: 5 - }); + wss.on("connection", (ws) => { + ws.send("welcome"); + }); - await Promise.all([ - new Promise((resolve) => { - ps1.addEventListener("open", () => { - expect(ps1.room).toBe("room1"); - expect(ps1.name).toBe("party1"); - resolve(); - }); - }), - new Promise((resolve) => { - ps2.addEventListener("open", () => { - expect(ps2.room).toBe("room2"); - expect(ps2.name).toBe("party2"); - resolve(); - }); - }) - ]); + const promises = Array.from({ length: socketCount }, (_, i) => { + return new Promise((resolve) => { + const ps = new PartySocket({ + host: `localhost:${PORT + 1}`, + room: `room-${i}` + }); + + ps.addEventListener("message", () => { + resolve(); + }); - ps1.close(); - ps2.close(); - }); -}); + sockets.push(ps); + }); + }); -describe.skip("Integration - Real-World Scenarios", () => { - let wss: WebSocketServer; + await Promise.all(promises); - beforeAll(() => { - wss = new WebSocketServer({ port: PORT + 2 }); - }); + // All sockets should be open + for (const socket of sockets) { + expect(socket.readyState).toBe(WebSocket.OPEN); + socket.close(); + } + }); - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); + test("sockets with different configurations work independently", async () => { + const ps1 = new PartySocket({ + host: `localhost:${PORT + 1}`, + room: "room1", + party: "party1", + debug: true }); - wss.close(() => { - resolve(); + + const ps2 = new PartySocket({ + host: `localhost:${PORT + 1}`, + room: "room2", + party: "party2", + maxRetries: 5 }); + + await Promise.all([ + new Promise((resolve) => { + ps1.addEventListener("open", () => { + expect(ps1.room).toBe("room1"); + expect(ps1.name).toBe("party1"); + resolve(); + }); + }), + new Promise((resolve) => { + ps2.addEventListener("open", () => { + expect(ps2.room).toBe("room2"); + expect(ps2.name).toBe("party2"); + resolve(); + }); + }) + ]); + + ps1.close(); + ps2.close(); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Integration - Real-World Scenarios", + () => { + let wss: WebSocketServer; + + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 2 }); }); - }); - - test("chat application scenario", async () => { - const users: Array<{ - id: string; - socket: PartySocket; - messages: string[]; - }> = []; - - wss.on("connection", (ws) => { - ws.on("message", (data) => { - // Broadcast to all clients + + afterAll(() => { + return new Promise((resolve) => { wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(data); - } + client.terminate(); + }); + wss.close(() => { + resolve(); }); }); }); - // Create 3 users - for (let i = 0; i < 3; i++) { - const userId = `user-${i}`; - const socket = new PartySocket({ - host: `localhost:${PORT + 2}`, - room: "chat-room", - id: userId + test("chat application scenario", async () => { + const users: Array<{ + id: string; + socket: PartySocket; + messages: string[]; + }> = []; + + wss.on("connection", (ws) => { + ws.on("message", (data) => { + // Broadcast to all clients + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(data); + } + }); + }); }); - users.push({ id: userId, socket, messages: [] }); - } - - // Wait for all to connect - await Promise.all( - users.map( - (user) => - new Promise((resolve) => { - user.socket.addEventListener("open", () => { - user.socket.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - user.messages.push(text); + // Create 3 users + for (let i = 0; i < 3; i++) { + const userId = `user-${i}`; + const socket = new PartySocket({ + host: `localhost:${PORT + 2}`, + room: "chat-room", + id: userId + }); + + users.push({ id: userId, socket, messages: [] }); + } + + // Wait for all to connect + await Promise.all( + users.map( + (user) => + new Promise((resolve) => { + user.socket.addEventListener("open", () => { + user.socket.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + user.messages.push(text); + }); + resolve(); }); - resolve(); - }); - }) - ) - ); - - // User 0 sends a message - users[0].socket.send("Hello from user 0!"); - - // Wait for messages to propagate with polling - await new Promise((resolve) => { - const checkInterval = setInterval(() => { - // Check if all users received at least one message - const allReceived = users.every((user) => user.messages.length > 0); - if (allReceived) { + }) + ) + ); + + // User 0 sends a message + users[0].socket.send("Hello from user 0!"); + + // Wait for messages to propagate with polling + await new Promise((resolve) => { + const checkInterval = setInterval(() => { + // Check if all users received at least one message + const allReceived = users.every((user) => user.messages.length > 0); + if (allReceived) { + clearInterval(checkInterval); + resolve(); + } + }, 50); + + // Timeout after 2 seconds + setTimeout(() => { clearInterval(checkInterval); resolve(); - } - }, 50); + }, 2000); + }); - // Timeout after 2 seconds - setTimeout(() => { - clearInterval(checkInterval); - resolve(); - }, 2000); + // All users should have received the message + for (const user of users) { + expect(user.messages.length).toBeGreaterThan(0); + user.socket.close(); + } }); - // All users should have received the message - for (const user of users) { - expect(user.messages.length).toBeGreaterThan(0); - user.socket.close(); - } - }); - - test("collaborative editing scenario", async () => { - const operations: string[] = []; - let connectionCount = 0; - - // Use a fresh handler for this test - const handler = (ws: WSWebSocket) => { - connectionCount++; - if (connectionCount > 1) return; // Only handle first connection - - ws.on("message", (data) => { - // Server processes operation and sends back confirmation - const operation = data.toString(); - operations.push(operation); - ws.send(`ack:${operation}`); - }); - }; + test("collaborative editing scenario", async () => { + const operations: string[] = []; + let connectionCount = 0; - wss.on("connection", handler); + // Use a fresh handler for this test + const handler = (ws: WSWebSocket) => { + connectionCount++; + if (connectionCount > 1) return; // Only handle first connection - const editor = new PartySocket({ - host: `localhost:${PORT + 2}`, - room: "document-123" - }); + ws.on("message", (data) => { + // Server processes operation and sends back confirmation + const operation = data.toString(); + operations.push(operation); + ws.send(`ack:${operation}`); + }); + }; - const acks: string[] = []; - let resolved = false; + wss.on("connection", handler); - await new Promise((resolve) => { - // Set up message handler - editor.addEventListener("message", async (event) => { - if (resolved) return; // Ignore messages after we're done + const editor = new PartySocket({ + host: `localhost:${PORT + 2}`, + room: "document-123" + }); - const text = await getMessageText(event.data); + const acks: string[] = []; + let resolved = false; - // Only count ack messages - if (text.startsWith("ack:")) { - acks.push(text); + await new Promise((resolve) => { + // Set up message handler + editor.addEventListener("message", async (event) => { + if (resolved) return; // Ignore messages after we're done - if (acks.length === 3) { - resolved = true; - resolve(); - } - } - }); + const text = await getMessageText(event.data); - editor.addEventListener("open", () => { - // Send operations - editor.send("insert:0:H"); - editor.send("insert:1:i"); - editor.send("insert:2:!"); + // Only count ack messages + if (text.startsWith("ack:")) { + acks.push(text); - // Timeout after 2 seconds - setTimeout(() => { - if (!resolved) { - resolved = true; - resolve(); + if (acks.length === 3) { + resolved = true; + resolve(); + } } - }, 2000); - }); - }); + }); - // Clean up handler - wss.off("connection", handler); + editor.addEventListener("open", () => { + // Send operations + editor.send("insert:0:H"); + editor.send("insert:1:i"); + editor.send("insert:2:!"); - // Should have received exactly 3 acks - expect(acks.length).toBe(3); - expect(operations).toEqual(["insert:0:H", "insert:1:i", "insert:2:!"]); + // Timeout after 2 seconds + setTimeout(() => { + if (!resolved) { + resolved = true; + resolve(); + } + }, 2000); + }); + }); - editor.close(); - }); + // Clean up handler + wss.off("connection", handler); - test("gaming scenario with frequent updates", async () => { - const updates: number[] = []; + // Should have received exactly 3 acks + expect(acks.length).toBe(3); + expect(operations).toEqual(["insert:0:H", "insert:1:i", "insert:2:!"]); - wss.once("connection", (ws) => { - // Simulate game server sending position updates - const interval = setInterval(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(`pos:${Math.random()},${Math.random()}`); - } else { - clearInterval(interval); - } - }, 10); + editor.close(); }); - const gameClient = new PartySocket({ - host: `localhost:${PORT + 2}`, - room: "game-room", - id: "player-1" - }); + test("gaming scenario with frequent updates", async () => { + const updates: number[] = []; - await new Promise((resolve) => { - gameClient.addEventListener("open", () => { - gameClient.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - if (text.startsWith("pos:")) { - updates.push(Date.now()); + wss.once("connection", (ws) => { + // Simulate game server sending position updates + const interval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(`pos:${Math.random()},${Math.random()}`); + } else { + clearInterval(interval); } + }, 10); + }); - // After receiving 50 updates, stop - if (updates.length >= 50) { - gameClient.close(); - resolve(); - } + const gameClient = new PartySocket({ + host: `localhost:${PORT + 2}`, + room: "game-room", + id: "player-1" + }); + + await new Promise((resolve) => { + gameClient.addEventListener("open", () => { + gameClient.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + if (text.startsWith("pos:")) { + updates.push(Date.now()); + } + + // After receiving 50 updates, stop + if (updates.length >= 50) { + gameClient.close(); + resolve(); + } + }); }); }); - }); - // Should receive updates frequently - expect(updates.length).toBeGreaterThanOrEqual(50); + // Should receive updates frequently + expect(updates.length).toBeGreaterThanOrEqual(50); - // Updates should be reasonably spaced (not bunched) - const avgInterval = - (updates[updates.length - 1] - updates[0]) / updates.length; - expect(avgInterval).toBeLessThan(100); // Less than 100ms average - }, 10000); -}); + // Updates should be reasonably spaced (not bunched) + const avgInterval = + (updates[updates.length - 1] - updates[0]) / updates.length; + expect(avgInterval).toBeLessThan(100); // Less than 100ms average + }, 10000); + } +); -describe.skip("Integration - Error Recovery", () => { - let wss: WebSocketServer; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Integration - Error Recovery", + () => { + let wss: WebSocketServer; - beforeAll(() => { - wss = new WebSocketServer({ port: PORT + 3 }); - }); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 3 }); + }); - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); }); }); - }); - - test("recovers from network interruption", async () => { - let connectionAttempts = 0; - - const handler = (ws: WebSocket) => { - connectionAttempts++; - if (connectionAttempts === 1) { - // First connection succeeds then fails - setTimeout(() => ws.close(), 100); - } else { - // Subsequent connections succeed - ws.send("recovered"); - } - }; - wss.on("connection", handler); + test("recovers from network interruption", async () => { + let connectionAttempts = 0; - const ps = new PartySocket({ - host: `localhost:${PORT + 3}`, - room: "recovery-test", - minReconnectionDelay: 50, - maxReconnectionDelay: 100 - }); + const handler = (ws: WebSocket) => { + connectionAttempts++; + if (connectionAttempts === 1) { + // First connection succeeds then fails + setTimeout(() => ws.close(), 100); + } else { + // Subsequent connections succeed + ws.send("recovered"); + } + }; - let recovered = false; + wss.on("connection", handler); - await new Promise((resolve) => { - ps.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - if (text === "recovered") { - recovered = true; - ps.close(); - wss.off("connection", handler); - resolve(); - } + const ps = new PartySocket({ + host: `localhost:${PORT + 3}`, + room: "recovery-test", + minReconnectionDelay: 50, + maxReconnectionDelay: 100 }); - }); - expect(recovered).toBe(true); - expect(connectionAttempts).toBeGreaterThanOrEqual(2); - }, 5000); + let recovered = false; - test("handles server restart", async () => { - let serverVersion = 1; + await new Promise((resolve) => { + ps.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + if (text === "recovered") { + recovered = true; + ps.close(); + wss.off("connection", handler); + resolve(); + } + }); + }); - const handler = (ws: WebSocket) => { - ws.send(`server-v${serverVersion}`); - }; + expect(recovered).toBe(true); + expect(connectionAttempts).toBeGreaterThanOrEqual(2); + }, 5000); - wss.on("connection", handler); + test("handles server restart", async () => { + let serverVersion = 1; - const ps = new PartySocket({ - host: `localhost:${PORT + 3}`, - room: "restart-test", - minReconnectionDelay: 50 - }); + const handler = (ws: WebSocket) => { + ws.send(`server-v${serverVersion}`); + }; - const versions: string[] = []; + wss.on("connection", handler); - await new Promise((resolve) => { - ps.addEventListener("message", async (event) => { - const text = await getMessageText(event.data); - versions.push(text); + const ps = new PartySocket({ + host: `localhost:${PORT + 3}`, + room: "restart-test", + minReconnectionDelay: 50 + }); - if (versions.length === 1) { - // Simulate server restart - serverVersion = 2; - ps.reconnect(); - } else if (versions.length === 2) { - ps.close(); - wss.off("connection", handler); - resolve(); - } + const versions: string[] = []; + + await new Promise((resolve) => { + ps.addEventListener("message", async (event) => { + const text = await getMessageText(event.data); + versions.push(text); + + if (versions.length === 1) { + // Simulate server restart + serverVersion = 2; + ps.reconnect(); + } else if (versions.length === 2) { + ps.close(); + wss.off("connection", handler); + resolve(); + } + }); }); - }); - expect(versions).toEqual(["server-v1", "server-v2"]); - }); -}); + expect(versions).toEqual(["server-v1", "server-v2"]); + }); + } +); -describe.skip("Integration - PartySocket.fetch with WebSocket", () => { - let wss: WebSocketServer; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Integration - PartySocket.fetch with WebSocket", + () => { + let wss: WebSocketServer; - beforeAll(() => { - wss = new WebSocketServer({ port: PORT + 4 }); - }); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 4 }); + }); - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); }); }); - }); - test("fetch and WebSocket use same URL structure", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + test("fetch and WebSocket use same URL structure", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); - const options = { - host: "example.com", - room: "test-room", - party: "test-party" - }; + const options = { + host: "example.com", + room: "test-room", + party: "test-party" + }; - await PartySocket.fetch({ ...options, fetch: mockFetch }); - const fetchUrl = mockFetch.mock.calls[0][0]; + await PartySocket.fetch({ ...options, fetch: mockFetch }); + const fetchUrl = mockFetch.mock.calls[0][0]; - const ps = new PartySocket({ ...options, startClosed: true }); - const wsUrl = ps.roomUrl; + const ps = new PartySocket({ ...options, startClosed: true }); + const wsUrl = ps.roomUrl; - // Extract path after protocol - const fetchPath = fetchUrl.split("://")[1].split("?")[0]; - const wsPath = wsUrl.split("://")[1]; + // Extract path after protocol + const fetchPath = fetchUrl.split("://")[1].split("?")[0]; + const wsPath = wsUrl.split("://")[1]; - expect(fetchPath).toBe(wsPath); + expect(fetchPath).toBe(wsPath); - ps.close(); - }); -}); + ps.close(); + }); + } +); diff --git a/packages/partysocket/src/tests/partysocket-fetch.test.ts b/packages/partysocket/src/tests/partysocket-fetch.test.ts index c3cfb4a..34839fd 100644 --- a/packages/partysocket/src/tests/partysocket-fetch.test.ts +++ b/packages/partysocket/src/tests/partysocket-fetch.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test, vitest } from "vitest"; import PartySocket from "../index"; -describe.skip("PartySocket.fetch", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("PartySocket.fetch", () => { test("constructs HTTP URL correctly", async () => { const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); @@ -344,45 +344,48 @@ describe.skip("PartySocket.fetch", () => { }); }); -describe.skip("PartySocket.fetch edge cases", () => { - test("handles empty query object", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket.fetch edge cases", + () => { + test("handles empty query object", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); - await PartySocket.fetch({ - host: "example.com", - room: "my-room", - query: {}, - fetch: mockFetch + await PartySocket.fetch({ + host: "example.com", + room: "my-room", + query: {}, + fetch: mockFetch + }); + + const calledUrl = mockFetch.mock.calls[0][0]; + // Should still end with ? but no params + expect(calledUrl).toMatch(/\?$/); }); - const calledUrl = mockFetch.mock.calls[0][0]; - // Should still end with ? but no params - expect(calledUrl).toMatch(/\?$/); - }); + test("handles query provider returning empty object", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); - test("handles query provider returning empty object", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + await PartySocket.fetch({ + host: "example.com", + room: "my-room", + query: () => ({}), + fetch: mockFetch + }); - await PartySocket.fetch({ - host: "example.com", - room: "my-room", - query: () => ({}), - fetch: mockFetch + expect(mockFetch).toHaveBeenCalled(); }); - expect(mockFetch).toHaveBeenCalled(); - }); + test("works without optional parameters", async () => { + const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); - test("works without optional parameters", async () => { - const mockFetch = vitest.fn().mockResolvedValue(new Response("ok")); + await PartySocket.fetch({ + host: "example.com", + room: "my-room", + fetch: mockFetch + }); - await PartySocket.fetch({ - host: "example.com", - room: "my-room", - fetch: mockFetch + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toBe("https://example.com/parties/main/my-room?"); }); - - const calledUrl = mockFetch.mock.calls[0][0]; - expect(calledUrl).toBe("https://example.com/parties/main/my-room?"); - }); -}); + } +); diff --git a/packages/partysocket/src/tests/partysocket-url.test.ts b/packages/partysocket/src/tests/partysocket-url.test.ts index 8a979d0..3b08306 100644 --- a/packages/partysocket/src/tests/partysocket-url.test.ts +++ b/packages/partysocket/src/tests/partysocket-url.test.ts @@ -6,280 +6,286 @@ import { afterEach, beforeEach, describe, expect, test, vitest } from "vitest"; import PartySocket from "../index"; -describe.skip("PartySocket URL Construction", () => { - test("constructs URL from host and room", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket URL Construction", + () => { + test("constructs URL from host and room", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); }); - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); - }); - test("constructs URL with custom party", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - party: "custom", - startClosed: true + test("constructs URL with custom party", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + party: "custom", + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/parties/custom/my-room"); }); - expect(ps.roomUrl).toBe("wss://example.com/parties/custom/my-room"); - }); - test("uses ws:// for localhost", () => { - const ps = new PartySocket({ - host: "localhost:1999", - room: "test", - startClosed: true + test("uses ws:// for localhost", () => { + const ps = new PartySocket({ + host: "localhost:1999", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://localhost:1999"); }); - expect(ps.roomUrl).toContain("ws://localhost:1999"); - }); - test("uses ws:// for 127.0.0.1", () => { - const ps = new PartySocket({ - host: "127.0.0.1:1999", - room: "test", - startClosed: true + test("uses ws:// for 127.0.0.1", () => { + const ps = new PartySocket({ + host: "127.0.0.1:1999", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://127.0.0.1:1999"); }); - expect(ps.roomUrl).toContain("ws://127.0.0.1:1999"); - }); - test("uses ws:// for private IP 192.168.x.x", () => { - const ps = new PartySocket({ - host: "192.168.1.1", - room: "test", - startClosed: true + test("uses ws:// for private IP 192.168.x.x", () => { + const ps = new PartySocket({ + host: "192.168.1.1", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://192.168.1.1"); }); - expect(ps.roomUrl).toContain("ws://192.168.1.1"); - }); - test("uses ws:// for private IP 10.x.x.x", () => { - const ps = new PartySocket({ - host: "10.0.0.1", - room: "test", - startClosed: true + test("uses ws:// for private IP 10.x.x.x", () => { + const ps = new PartySocket({ + host: "10.0.0.1", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://10.0.0.1"); }); - expect(ps.roomUrl).toContain("ws://10.0.0.1"); - }); - test("uses ws:// for private IP 172.16-31.x.x", () => { - const ps = new PartySocket({ - host: "172.16.0.1", - room: "test", - startClosed: true + test("uses ws:// for private IP 172.16-31.x.x", () => { + const ps = new PartySocket({ + host: "172.16.0.1", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://172.16.0.1"); + + const ps2 = new PartySocket({ + host: "172.31.255.255", + room: "test", + startClosed: true + }); + expect(ps2.roomUrl).toContain("ws://172.31.255.255"); }); - expect(ps.roomUrl).toContain("ws://172.16.0.1"); - const ps2 = new PartySocket({ - host: "172.31.255.255", - room: "test", - startClosed: true + test("uses ws:// for IPv6 localhost", () => { + const ps = new PartySocket({ + host: "[::ffff:7f00:1]:1999", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://[::ffff:7f00:1]:1999"); }); - expect(ps2.roomUrl).toContain("ws://172.31.255.255"); - }); - test("uses ws:// for IPv6 localhost", () => { - const ps = new PartySocket({ - host: "[::ffff:7f00:1]:1999", - room: "test", - startClosed: true + test("uses wss:// for public domains", () => { + const ps = new PartySocket({ + host: "example.com", + room: "test", + startClosed: true + }); + expect(ps.roomUrl).toContain("wss://example.com"); }); - expect(ps.roomUrl).toContain("ws://[::ffff:7f00:1]:1999"); - }); - test("uses wss:// for public domains", () => { - const ps = new PartySocket({ - host: "example.com", - room: "test", - startClosed: true + test("strips protocol from host (https)", () => { + const ps = new PartySocket({ + host: "https://example.com", + room: "my-room", + startClosed: true + }); + expect(ps.host).toBe("example.com"); + expect(ps.roomUrl).not.toContain("https://https://"); }); - expect(ps.roomUrl).toContain("wss://example.com"); - }); - test("strips protocol from host (https)", () => { - const ps = new PartySocket({ - host: "https://example.com", - room: "my-room", - startClosed: true + test("strips protocol from host (http)", () => { + const ps = new PartySocket({ + host: "http://example.com", + room: "my-room", + startClosed: true + }); + expect(ps.host).toBe("example.com"); }); - expect(ps.host).toBe("example.com"); - expect(ps.roomUrl).not.toContain("https://https://"); - }); - test("strips protocol from host (http)", () => { - const ps = new PartySocket({ - host: "http://example.com", - room: "my-room", - startClosed: true + test("strips protocol from host (ws)", () => { + const ps = new PartySocket({ + host: "ws://example.com", + room: "my-room", + startClosed: true + }); + expect(ps.host).toBe("example.com"); }); - expect(ps.host).toBe("example.com"); - }); - test("strips protocol from host (ws)", () => { - const ps = new PartySocket({ - host: "ws://example.com", - room: "my-room", - startClosed: true + test("strips protocol from host (wss)", () => { + const ps = new PartySocket({ + host: "wss://example.com", + room: "my-room", + startClosed: true + }); + expect(ps.host).toBe("example.com"); }); - expect(ps.host).toBe("example.com"); - }); - test("strips protocol from host (wss)", () => { - const ps = new PartySocket({ - host: "wss://example.com", - room: "my-room", - startClosed: true + test("handles trailing slash in host", () => { + const ps = new PartySocket({ + host: "example.com/", + room: "my-room", + startClosed: true + }); + expect(ps.host).toBe("example.com"); + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); }); - expect(ps.host).toBe("example.com"); - }); - test("handles trailing slash in host", () => { - const ps = new PartySocket({ - host: "example.com/", - room: "my-room", - startClosed: true + test("throws when path starts with slash", () => { + expect(() => { + new PartySocket({ + host: "example.com", + room: "my-room", + path: "/invalid" + }); + }).toThrow("path must not start with a slash"); }); - expect(ps.host).toBe("example.com"); - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); - }); - test("throws when path starts with slash", () => { - expect(() => { - new PartySocket({ + test("includes path in URL when provided", () => { + const ps = new PartySocket({ host: "example.com", room: "my-room", - path: "/invalid" + path: "subpath", + startClosed: true }); - }).toThrow("path must not start with a slash"); - }); - - test("includes path in URL when provided", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - path: "subpath", - startClosed: true - }); - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room/subpath"); - expect(ps.path).toBe("/subpath"); - }); - - test("uses basePath when provided", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - basePath: "custom/base/path", - startClosed: true + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room/subpath"); + expect(ps.path).toBe("/subpath"); }); - expect(ps.roomUrl).toBe("wss://example.com/custom/base/path"); - }); - test("uses prefix when provided", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - party: "custom-party", - prefix: "rooms", - startClosed: true + test("uses basePath when provided", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + basePath: "custom/base/path", + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/custom/base/path"); }); - expect(ps.roomUrl).toBe("wss://example.com/rooms/custom-party/my-room"); - }); - test("basePath takes precedence over prefix", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - basePath: "absolute/path", - prefix: "should-be-ignored", - startClosed: true + test("uses prefix when provided", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + party: "custom-party", + prefix: "rooms", + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/rooms/custom-party/my-room"); }); - expect(ps.roomUrl).toBe("wss://example.com/absolute/path"); - }); - test("can override protocol with explicit option", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - protocol: "ws", - startClosed: true + test("basePath takes precedence over prefix", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + basePath: "absolute/path", + prefix: "should-be-ignored", + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/absolute/path"); }); - expect(ps.roomUrl).toContain("ws://example.com"); - }); - test("defaults party to 'main' when not provided", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true + test("can override protocol with explicit option", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + protocol: "ws", + startClosed: true + }); + expect(ps.roomUrl).toContain("ws://example.com"); }); - expect(ps.name).toBe("main"); - expect(ps.roomUrl).toContain("/parties/main/"); - }); -}); -describe.skip("PartySocket Query Parameters", () => { - test("includes connection ID in query params", () => { - const customId = "custom-connection-id"; - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - id: customId, - startClosed: true + test("defaults party to 'main' when not provided", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); + expect(ps.name).toBe("main"); + expect(ps.roomUrl).toContain("/parties/main/"); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket Query Parameters", + () => { + test("includes connection ID in query params", () => { + const customId = "custom-connection-id"; + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + id: customId, + startClosed: true + }); + expect(ps.id).toBe(customId); + // ID is added to URL via _pk parameter }); - expect(ps.id).toBe(customId); - // ID is added to URL via _pk parameter - }); - test("generates random ID when not provided", () => { - const ps1 = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true - }); - const ps2 = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true + test("generates random ID when not provided", () => { + const ps1 = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); + const ps2 = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); + expect(ps1.id).toBeDefined(); + expect(ps2.id).toBeDefined(); + expect(ps1.id).not.toBe(ps2.id); }); - expect(ps1.id).toBeDefined(); - expect(ps2.id).toBeDefined(); - expect(ps1.id).not.toBe(ps2.id); - }); - test("adds static query parameters", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - query: { foo: "bar", baz: "qux" }, - startClosed: true + test("adds static query parameters", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + query: { foo: "bar", baz: "qux" }, + startClosed: true + }); + // Query params are added when URL is resolved + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); }); - // Query params are added when URL is resolved - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); - }); - test("omits null and undefined query parameters", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - query: { foo: "bar", nullParam: null, undefinedParam: undefined }, - startClosed: true + test("omits null and undefined query parameters", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + query: { foo: "bar", nullParam: null, undefinedParam: undefined }, + startClosed: true + }); + // Only non-null params should be included + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); }); - // Only non-null params should be included - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); - }); - test("handles empty query object", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - query: {}, - startClosed: true + test("handles empty query object", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + query: {}, + startClosed: true + }); + expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); }); - expect(ps.roomUrl).toBe("wss://example.com/parties/main/my-room"); - }); -}); + } +); -describe.skip("PartySocket Properties", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("PartySocket Properties", () => { test("exposes host property", () => { const ps = new PartySocket({ host: "example.com", @@ -340,169 +346,175 @@ describe.skip("PartySocket Properties", () => { }); }); -describe.skip("PartySocket.updateProperties", () => { - test("updates room", () => { - const ps = new PartySocket({ - host: "example.com", - room: "old-room", - startClosed: true +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket.updateProperties", + () => { + test("updates room", () => { + const ps = new PartySocket({ + host: "example.com", + room: "old-room", + startClosed: true + }); + ps.updateProperties({ room: "new-room" }); + expect(ps.room).toBe("new-room"); }); - ps.updateProperties({ room: "new-room" }); - expect(ps.room).toBe("new-room"); - }); - test("updates host", () => { - const ps = new PartySocket({ - host: "old.com", - room: "my-room", - startClosed: true + test("updates host", () => { + const ps = new PartySocket({ + host: "old.com", + room: "my-room", + startClosed: true + }); + ps.updateProperties({ host: "new.com" }); + expect(ps.host).toBe("new.com"); }); - ps.updateProperties({ host: "new.com" }); - expect(ps.host).toBe("new.com"); - }); - test("updates party", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - party: "old-party", - startClosed: true + test("updates party", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + party: "old-party", + startClosed: true + }); + ps.updateProperties({ party: "new-party" }); + expect(ps.name).toBe("new-party"); }); - ps.updateProperties({ party: "new-party" }); - expect(ps.name).toBe("new-party"); - }); - test("updates path", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - path: "old-path", - startClosed: true + test("updates path", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + path: "old-path", + startClosed: true + }); + ps.updateProperties({ path: "new-path" }); + expect(ps.path).toBe("/new-path"); }); - ps.updateProperties({ path: "new-path" }); - expect(ps.path).toBe("/new-path"); - }); - test("updates multiple properties at once", () => { - const ps = new PartySocket({ - host: "old.com", - room: "old-room", - startClosed: true - }); - ps.updateProperties({ - host: "new.com", - room: "new-room", - party: "new-party" + test("updates multiple properties at once", () => { + const ps = new PartySocket({ + host: "old.com", + room: "old-room", + startClosed: true + }); + ps.updateProperties({ + host: "new.com", + room: "new-room", + party: "new-party" + }); + expect(ps.host).toBe("new.com"); + expect(ps.room).toBe("new-room"); + expect(ps.name).toBe("new-party"); }); - expect(ps.host).toBe("new.com"); - expect(ps.room).toBe("new-room"); - expect(ps.name).toBe("new-party"); - }); - test("preserves existing properties when partially updating", () => { - const ps = new PartySocket({ - host: "example.com", - room: "room1", - party: "custom", - startClosed: true + test("preserves existing properties when partially updating", () => { + const ps = new PartySocket({ + host: "example.com", + room: "room1", + party: "custom", + startClosed: true + }); + ps.updateProperties({ room: "room2" }); + expect(ps.host).toBe("example.com"); + expect(ps.name).toBe("custom"); + expect(ps.room).toBe("room2"); }); - ps.updateProperties({ room: "room2" }); - expect(ps.host).toBe("example.com"); - expect(ps.name).toBe("custom"); - expect(ps.room).toBe("room2"); - }); - test("updates query parameters", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - query: { old: "value" }, - startClosed: true + test("updates query parameters", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + query: { old: "value" }, + startClosed: true + }); + ps.updateProperties({ query: { new: "value" } }); + // Query is part of options, should be updated }); - ps.updateProperties({ query: { new: "value" } }); - // Query is part of options, should be updated - }); - test("throws when reconnecting without host and room", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - startClosed: true + test("throws when reconnecting without host and room", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + startClosed: true + }); + ps.updateProperties({ host: "", room: "" }); + expect(() => { + ps.reconnect(); + }).toThrow("The host must be set"); }); - ps.updateProperties({ host: "", room: "" }); - expect(() => { - ps.reconnect(); - }).toThrow("The host must be set"); - }); -}); + } +); -describe.skip("PartySocket Name Validation", () => { - let warnSpy: ReturnType; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket Name Validation", + () => { + let warnSpy: ReturnType; - beforeEach(() => { - warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}); - }); + beforeEach(() => { + warnSpy = vitest.spyOn(console, "warn").mockImplementation(() => {}); + }); - afterEach(() => { - warnSpy.mockRestore(); - }); + afterEach(() => { + warnSpy.mockRestore(); + }); - test("warns when party name contains forward slash", () => { - new PartySocket({ - host: "example.com", - room: "room", - party: "bad/party", - startClosed: true + test("warns when party name contains forward slash", () => { + new PartySocket({ + host: "example.com", + room: "room", + party: "bad/party", + startClosed: true + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('party name "bad/party" contains forward slash') + ); }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('party name "bad/party" contains forward slash') - ); - }); - test("warns when room name contains forward slash", () => { - new PartySocket({ - host: "example.com", - room: "bad/room", - startClosed: true + test("warns when room name contains forward slash", () => { + new PartySocket({ + host: "example.com", + room: "bad/room", + startClosed: true + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('room name "bad/room" contains forward slash') + ); }); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('room name "bad/room" contains forward slash') - ); - }); - test("warns for both party and room with forward slashes", () => { - new PartySocket({ - host: "example.com", - room: "bad/room", - party: "bad/party", - startClosed: true + test("warns for both party and room with forward slashes", () => { + new PartySocket({ + host: "example.com", + room: "bad/room", + party: "bad/party", + startClosed: true + }); + expect(warnSpy).toHaveBeenCalledTimes(2); }); - expect(warnSpy).toHaveBeenCalledTimes(2); - }); - test("can disable name validation", () => { - new PartySocket({ - host: "example.com", - room: "bad/room", - party: "bad/party", - disableNameValidation: true, - startClosed: true + test("can disable name validation", () => { + new PartySocket({ + host: "example.com", + room: "bad/room", + party: "bad/party", + disableNameValidation: true, + startClosed: true + }); + expect(warnSpy).not.toHaveBeenCalled(); }); - expect(warnSpy).not.toHaveBeenCalled(); - }); - test("does not warn for valid names", () => { - new PartySocket({ - host: "example.com", - room: "valid-room", - party: "valid-party", - startClosed: true + test("does not warn for valid names", () => { + new PartySocket({ + host: "example.com", + room: "valid-room", + party: "valid-party", + startClosed: true + }); + expect(warnSpy).not.toHaveBeenCalled(); }); - expect(warnSpy).not.toHaveBeenCalled(); - }); -}); + } +); -describe.skip("PartySocket Protocols", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("PartySocket Protocols", () => { test("accepts protocols array", () => { const ps = new PartySocket({ host: "example.com", @@ -524,66 +536,69 @@ describe.skip("PartySocket Protocols", () => { }); }); -describe.skip("PartySocket Options Passthrough", () => { - test("passes maxRetries to ReconnectingWebSocket", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - maxRetries: 5, - startClosed: true +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "PartySocket Options Passthrough", + () => { + test("passes maxRetries to ReconnectingWebSocket", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + maxRetries: 5, + startClosed: true + }); + expect(ps).toBeDefined(); + // Option is passed through to parent class }); - expect(ps).toBeDefined(); - // Option is passed through to parent class - }); - test("passes debug option to ReconnectingWebSocket", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - debug: true, - startClosed: true + test("passes debug option to ReconnectingWebSocket", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + debug: true, + startClosed: true + }); + expect(ps).toBeDefined(); }); - expect(ps).toBeDefined(); - }); - test("passes connectionTimeout to ReconnectingWebSocket", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - connectionTimeout: 5000, - startClosed: true + test("passes connectionTimeout to ReconnectingWebSocket", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + connectionTimeout: 5000, + startClosed: true + }); + expect(ps).toBeDefined(); }); - expect(ps).toBeDefined(); - }); - test("passes minReconnectionDelay to ReconnectingWebSocket", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - minReconnectionDelay: 1000, - startClosed: true + test("passes minReconnectionDelay to ReconnectingWebSocket", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + minReconnectionDelay: 1000, + startClosed: true + }); + expect(ps).toBeDefined(); }); - expect(ps).toBeDefined(); - }); - test("passes maxReconnectionDelay to ReconnectingWebSocket", () => { - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - maxReconnectionDelay: 10000, - startClosed: true + test("passes maxReconnectionDelay to ReconnectingWebSocket", () => { + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + maxReconnectionDelay: 10000, + startClosed: true + }); + expect(ps).toBeDefined(); }); - expect(ps).toBeDefined(); - }); - test("passes custom WebSocket constructor", () => { - const customWS = class extends WebSocket {}; - const ps = new PartySocket({ - host: "example.com", - room: "my-room", - WebSocket: customWS, - startClosed: true + test("passes custom WebSocket constructor", () => { + const customWS = class extends WebSocket {}; + const ps = new PartySocket({ + host: "example.com", + room: "my-room", + WebSocket: customWS, + startClosed: true + }); + expect(ps).toBeDefined(); }); - expect(ps).toBeDefined(); - }); -}); + } +); diff --git a/packages/partysocket/src/tests/performance.test.ts b/packages/partysocket/src/tests/performance.test.ts index a6e9244..757eacb 100644 --- a/packages/partysocket/src/tests/performance.test.ts +++ b/packages/partysocket/src/tests/performance.test.ts @@ -10,429 +10,447 @@ import ReconnectingWebSocket from "../ws"; const PORT = 50130; -describe.skip("Performance - Message Throughput", () => { - let wss: WebSocketServer; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - Message Throughput", + () => { + let wss: WebSocketServer; - beforeAll(() => { - wss = new WebSocketServer({ port: PORT }); - }); - - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); - }); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT }); }); - }); - - test("handles high message volume", async () => { - const messageCount = 1000; - let receivedCount = 0; - wss.once("connection", (ws) => { - ws.on("message", (data) => { - ws.send(data); // Echo back + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); }); }); - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "perf-test" - }); + test("handles high message volume", async () => { + const messageCount = 1000; + let receivedCount = 0; - await new Promise((resolve) => { - ps.addEventListener("open", () => { - const startTime = performance.now(); + wss.once("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); // Echo back + }); + }); - ps.addEventListener("message", () => { - receivedCount++; - if (receivedCount === messageCount) { - const endTime = performance.now(); - const duration = endTime - startTime; - const messagesPerSecond = (messageCount / duration) * 1000; + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "perf-test" + }); - // Should handle at least 100 messages per second - expect(messagesPerSecond).toBeGreaterThan(100); - ps.close(); - resolve(); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + const startTime = performance.now(); + + ps.addEventListener("message", () => { + receivedCount++; + if (receivedCount === messageCount) { + const endTime = performance.now(); + const duration = endTime - startTime; + const messagesPerSecond = (messageCount / duration) * 1000; + + // Should handle at least 100 messages per second + expect(messagesPerSecond).toBeGreaterThan(100); + ps.close(); + resolve(); + } + }); + + // Send messages rapidly + for (let i = 0; i < messageCount; i++) { + ps.send(`message-${i}`); } }); - - // Send messages rapidly - for (let i = 0; i < messageCount; i++) { - ps.send(`message-${i}`); - } }); - }); - }, 10000); + }, 10000); - test("handles rapid small messages efficiently", async () => { - let receivedCount = 0; - const messageCount = 500; + test("handles rapid small messages efficiently", async () => { + let receivedCount = 0; + const messageCount = 500; - wss.once("connection", (ws) => { - ws.on("message", (data) => { - ws.send(data); + wss.once("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); + }); }); - }); - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "small-messages" - }); + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "small-messages" + }); - await new Promise((resolve) => { - ps.addEventListener("open", () => { - ps.addEventListener("message", () => { - receivedCount++; - if (receivedCount === messageCount) { - ps.close(); - resolve(); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + ps.addEventListener("message", () => { + receivedCount++; + if (receivedCount === messageCount) { + ps.close(); + resolve(); + } + }); + + // Send small messages + for (let i = 0; i < messageCount; i++) { + ps.send("x"); } }); - - // Send small messages - for (let i = 0; i < messageCount; i++) { - ps.send("x"); - } }); - }); - expect(receivedCount).toBe(messageCount); - }, 10000); + expect(receivedCount).toBe(messageCount); + }, 10000); - test("handles large messages efficiently", async () => { - const largeMessage = "x".repeat(10000); // 10KB message - let received = false; + test("handles large messages efficiently", async () => { + const largeMessage = "x".repeat(10000); // 10KB message + let received = false; - wss.once("connection", (ws) => { - ws.on("message", (data) => { - ws.send(data); + wss.once("connection", (ws) => { + ws.on("message", (data) => { + ws.send(data); + }); }); - }); - const ps = new PartySocket({ - host: `localhost:${PORT}`, - room: "large-messages" - }); + const ps = new PartySocket({ + host: `localhost:${PORT}`, + room: "large-messages" + }); - await new Promise((resolve) => { - ps.addEventListener("open", () => { - const startTime = performance.now(); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + const startTime = performance.now(); - ps.addEventListener("message", (event) => { - const endTime = performance.now(); - const duration = endTime - startTime; + ps.addEventListener("message", (event) => { + const endTime = performance.now(); + const duration = endTime - startTime; - // Should handle large message in reasonable time - expect(duration).toBeLessThan(1000); - expect(event.data).toBeDefined(); + // Should handle large message in reasonable time + expect(duration).toBeLessThan(1000); + expect(event.data).toBeDefined(); - received = true; - ps.close(); - resolve(); - }); + received = true; + ps.close(); + resolve(); + }); - ps.send(largeMessage); + ps.send(largeMessage); + }); }); - }); - expect(received).toBe(true); - }); -}); + expect(received).toBe(true); + }); + } +); -describe.skip("Performance - Connection Speed", () => { - let wss: WebSocketServer; +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - Connection Speed", + () => { + let wss: WebSocketServer; - beforeAll(() => { - wss = new WebSocketServer({ port: PORT + 1 }); - }); + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 1 }); + }); - afterAll(() => { - return new Promise((resolve) => { - wss.clients.forEach((client) => { - client.terminate(); - }); - wss.close(() => { - resolve(); + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client) => { + client.terminate(); + }); + wss.close(() => { + resolve(); + }); }); }); - }); - test("connects quickly", async () => { - const startTime = performance.now(); + test("connects quickly", async () => { + const startTime = performance.now(); - const ps = new PartySocket({ - host: `localhost:${PORT + 1}`, - room: "speed-test" - }); + const ps = new PartySocket({ + host: `localhost:${PORT + 1}`, + room: "speed-test" + }); - await new Promise((resolve) => { - ps.addEventListener("open", () => { - const endTime = performance.now(); - const duration = endTime - startTime; + await new Promise((resolve) => { + ps.addEventListener("open", () => { + const endTime = performance.now(); + const duration = endTime - startTime; - // Should connect in less than 500ms on localhost - expect(duration).toBeLessThan(500); - ps.close(); - resolve(); + // Should connect in less than 500ms on localhost + expect(duration).toBeLessThan(500); + ps.close(); + resolve(); + }); }); }); - }); - test("reconnects quickly after disconnect", async () => { - wss.once("connection", (ws) => { - // Close after 100ms - setTimeout(() => ws.close(), 100); - }); + test("reconnects quickly after disconnect", async () => { + wss.once("connection", (ws) => { + // Close after 100ms + setTimeout(() => ws.close(), 100); + }); - wss.once("connection", () => { - // Second connection - measure reconnect time - }); + wss.once("connection", () => { + // Second connection - measure reconnect time + }); - const ps = new PartySocket({ - host: `localhost:${PORT + 1}`, - room: "reconnect-speed", - minReconnectionDelay: 50, - maxReconnectionDelay: 100 - }); + const ps = new PartySocket({ + host: `localhost:${PORT + 1}`, + room: "reconnect-speed", + minReconnectionDelay: 50, + maxReconnectionDelay: 100 + }); - let firstConnect = false; - let reconnectTime = 0; + let firstConnect = false; + let reconnectTime = 0; - await new Promise((resolve) => { - ps.addEventListener("open", () => { - if (!firstConnect) { - firstConnect = true; - } - }); + await new Promise((resolve) => { + ps.addEventListener("open", () => { + if (!firstConnect) { + firstConnect = true; + } + }); - ps.addEventListener("close", () => { - reconnectTime = performance.now(); - }); + ps.addEventListener("close", () => { + reconnectTime = performance.now(); + }); - ps.addEventListener("open", () => { - if (firstConnect && reconnectTime > 0) { - const duration = performance.now() - reconnectTime; - // Should reconnect within reasonable time - expect(duration).toBeLessThan(1000); - ps.close(); - resolve(); - } + ps.addEventListener("open", () => { + if (firstConnect && reconnectTime > 0) { + const duration = performance.now() - reconnectTime; + // Should reconnect within reasonable time + expect(duration).toBeLessThan(1000); + ps.close(); + resolve(); + } + }); }); }); - }); -}); - -describe.skip("Performance - Message Queue", () => { - test("respects maxEnqueuedMessages limit efficiently", () => { - const maxMessages = 10; - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - maxRetries: 0, - maxEnqueuedMessages: maxMessages - }); - - const startTime = performance.now(); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - Message Queue", + () => { + test("respects maxEnqueuedMessages limit efficiently", () => { + const maxMessages = 10; + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + maxRetries: 0, + maxEnqueuedMessages: maxMessages + }); - // Try to send many more messages than the limit - for (let i = 0; i < 10000; i++) { - ws.send(`message-${i}`); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + // Try to send many more messages than the limit + for (let i = 0; i < 10000; i++) { + ws.send(`message-${i}`); + } - // Should complete quickly even with many messages - expect(duration).toBeLessThan(100); + const endTime = performance.now(); + const duration = endTime - startTime; - // Should only have maxMessages in queue - expect(ws.bufferedAmount).toBeLessThanOrEqual(maxMessages * 15); + // Should complete quickly even with many messages + expect(duration).toBeLessThan(100); - ws.close(); - }); + // Should only have maxMessages in queue + expect(ws.bufferedAmount).toBeLessThanOrEqual(maxMessages * 15); - test("message queue operations are fast", () => { - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - maxRetries: 0, - maxEnqueuedMessages: 1000 + ws.close(); }); - const startTime = performance.now(); + test("message queue operations are fast", () => { + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + maxRetries: 0, + maxEnqueuedMessages: 1000 + }); - for (let i = 0; i < 1000; i++) { - ws.send(`msg-${i}`); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + for (let i = 0; i < 1000; i++) { + ws.send(`msg-${i}`); + } - // Queuing 1000 messages should be very fast - expect(duration).toBeLessThan(50); + const endTime = performance.now(); + const duration = endTime - startTime; - ws.close(); - }); -}); + // Queuing 1000 messages should be very fast + expect(duration).toBeLessThan(50); -describe.skip("Performance - Reconnection Logic", () => { - test("retry delay calculation is efficient", () => { - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - minReconnectionDelay: 1000, - maxReconnectionDelay: 10000, - reconnectionDelayGrowFactor: 1.3, - maxRetries: 100, - startClosed: true + ws.close(); }); + } +); - const startTime = performance.now(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - Reconnection Logic", + () => { + test("retry delay calculation is efficient", () => { + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + minReconnectionDelay: 1000, + maxReconnectionDelay: 10000, + reconnectionDelayGrowFactor: 1.3, + maxRetries: 100, + startClosed: true + }); - // Calculate delays for many retries - for (let i = 0; i < 100; i++) { - // @ts-expect-error - accessing private field for testing - ws._retryCount = i; - // @ts-expect-error - accessing private method for testing - ws._getNextDelay(); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + // Calculate delays for many retries + for (let i = 0; i < 100; i++) { + // @ts-expect-error - accessing private field for testing + ws._retryCount = i; + // @ts-expect-error - accessing private method for testing + ws._getNextDelay(); + } - // Should be very fast - expect(duration).toBeLessThan(10); + const endTime = performance.now(); + const duration = endTime - startTime; - ws.close(); - }); + // Should be very fast + expect(duration).toBeLessThan(10); - test("multiple reconnect calls don't cause performance issues", () => { - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - maxRetries: 0, - startClosed: true + ws.close(); }); - const startTime = performance.now(); + test("multiple reconnect calls don't cause performance issues", () => { + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + maxRetries: 0, + startClosed: true + }); - // Call reconnect many times - for (let i = 0; i < 100; i++) { - ws.reconnect(); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + // Call reconnect many times + for (let i = 0; i < 100; i++) { + ws.reconnect(); + } - // Should handle gracefully without hanging - expect(duration).toBeLessThan(100); + const endTime = performance.now(); + const duration = endTime - startTime; - ws.close(); - }); -}); + // Should handle gracefully without hanging + expect(duration).toBeLessThan(100); -describe.skip("Performance - Event Handling", () => { - test("adding many event listeners is efficient", () => { - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - startClosed: true + ws.close(); }); + } +); - const startTime = performance.now(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - Event Handling", + () => { + test("adding many event listeners is efficient", () => { + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + startClosed: true + }); - // Add many listeners - for (let i = 0; i < 100; i++) { - ws.addEventListener("open", () => {}); - ws.addEventListener("message", () => {}); - ws.addEventListener("close", () => {}); - ws.addEventListener("error", () => {}); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + // Add many listeners + for (let i = 0; i < 100; i++) { + ws.addEventListener("open", () => {}); + ws.addEventListener("message", () => {}); + ws.addEventListener("close", () => {}); + ws.addEventListener("error", () => {}); + } - // Should be fast - expect(duration).toBeLessThan(50); + const endTime = performance.now(); + const duration = endTime - startTime; - ws.close(); - }); + // Should be fast + expect(duration).toBeLessThan(50); - test("removing many event listeners is efficient", () => { - const ws = new ReconnectingWebSocket("ws://invalid", undefined, { - startClosed: true + ws.close(); }); - const listeners: Array<() => void> = []; + test("removing many event listeners is efficient", () => { + const ws = new ReconnectingWebSocket("ws://invalid", undefined, { + startClosed: true + }); - // Add listeners - for (let i = 0; i < 100; i++) { - const listener = () => {}; - listeners.push(listener); - ws.addEventListener("open", listener); - } + const listeners: Array<() => void> = []; - const startTime = performance.now(); + // Add listeners + for (let i = 0; i < 100; i++) { + const listener = () => {}; + listeners.push(listener); + ws.addEventListener("open", listener); + } - // Remove all listeners - for (const listener of listeners) { - ws.removeEventListener("open", listener); - } + const startTime = performance.now(); - const endTime = performance.now(); - const duration = endTime - startTime; + // Remove all listeners + for (const listener of listeners) { + ws.removeEventListener("open", listener); + } - // Should be fast - expect(duration).toBeLessThan(50); + const endTime = performance.now(); + const duration = endTime - startTime; - ws.close(); - }); -}); + // Should be fast + expect(duration).toBeLessThan(50); + + ws.close(); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "Performance - PartySocket Operations", + () => { + test("URL construction is fast", () => { + const startTime = performance.now(); + + for (let i = 0; i < 1000; i++) { + const ps = new PartySocket({ + host: "example.com", + room: `room-${i}`, + party: `party-${i}`, + query: { foo: "bar", baz: `value-${i}` }, + startClosed: true + }); + ps.close(); + } -describe.skip("Performance - PartySocket Operations", () => { - test("URL construction is fast", () => { - const startTime = performance.now(); + const endTime = performance.now(); + const duration = endTime - startTime; - for (let i = 0; i < 1000; i++) { + // Creating 1000 PartySocket instances should be reasonably fast + expect(duration).toBeLessThan(1000); + }); + + test("property updates are efficient", () => { const ps = new PartySocket({ host: "example.com", - room: `room-${i}`, - party: `party-${i}`, - query: { foo: "bar", baz: `value-${i}` }, + room: "test-room", startClosed: true }); - ps.close(); - } - const endTime = performance.now(); - const duration = endTime - startTime; + const startTime = performance.now(); - // Creating 1000 PartySocket instances should be reasonably fast - expect(duration).toBeLessThan(1000); - }); - - test("property updates are efficient", () => { - const ps = new PartySocket({ - host: "example.com", - room: "test-room", - startClosed: true - }); - - const startTime = performance.now(); - - for (let i = 0; i < 1000; i++) { - ps.updateProperties({ - room: `room-${i}`, - party: `party-${i}` - }); - } + for (let i = 0; i < 1000; i++) { + ps.updateProperties({ + room: `room-${i}`, + party: `party-${i}` + }); + } - const endTime = performance.now(); - const duration = endTime - startTime; + const endTime = performance.now(); + const duration = endTime - startTime; - // 1000 property updates should be fast - expect(duration).toBeLessThan(100); + // 1000 property updates should be fast + expect(duration).toBeLessThan(100); - ps.close(); - }); -}); + ps.close(); + }); + } +); -describe.skip("Performance - Memory", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("Performance - Memory", () => { test("closed sockets can be garbage collected", () => { const sockets: ReconnectingWebSocket[] = []; diff --git a/packages/partysocket/src/tests/react-hooks.test.tsx b/packages/partysocket/src/tests/react-hooks.test.tsx index 206af7c..a2c5262 100644 --- a/packages/partysocket/src/tests/react-hooks.test.tsx +++ b/packages/partysocket/src/tests/react-hooks.test.tsx @@ -11,7 +11,7 @@ import usePartySocket, { useWebSocket } from "../react"; const PORT = 50128; // const URL = `ws://localhost:${PORT}`; -describe.skip("usePartySocket", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("usePartySocket", () => { let wss: WebSocketServer; beforeAll(() => { @@ -310,7 +310,8 @@ describe.skip("usePartySocket", () => { expect(result.current).toBe(firstSocket); }); - test("attaches onOpen event handler", async () => { + // TODO: flaky — relies on WebSocket open event timing that doesn't work reliably + test.skip("attaches onOpen event handler", async () => { const onOpen = vitest.fn(); // Set up connection handler before rendering @@ -346,7 +347,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("attaches onMessage event handler", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("attaches onMessage event handler", async () => { const onMessage = vitest.fn(); const testMessage = "hello from server"; @@ -380,7 +382,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("attaches onClose event handler", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("attaches onClose event handler", async () => { const onClose = vitest.fn(); wss.once("connection", (ws) => { @@ -413,7 +416,8 @@ describe.skip("usePartySocket", () => { ); }); - test("attaches onError event handler", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("attaches onError event handler", async () => { const onError = vitest.fn(); const { result } = renderHook(() => @@ -435,7 +439,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("updates event handlers without reconnecting", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("updates event handlers without reconnecting", async () => { const onMessage1 = vitest.fn(); const onMessage2 = vitest.fn(); @@ -512,7 +517,8 @@ describe.skip("usePartySocket", () => { expect(result.current.readyState).toBe(WebSocket.CLOSED); }); - test("connects automatically when startClosed is false", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("connects automatically when startClosed is false", async () => { wss.once("connection", (_ws) => { // Connection established }); @@ -584,7 +590,8 @@ describe.skip("usePartySocket", () => { expect(result.current).not.toBe(firstSocket); }); - test("handles all event handlers together", async () => { + // TODO: flaky — depends on open event handler which has timing issues + test.skip("handles all event handlers together", async () => { const onOpen = vitest.fn(); const onMessage = vitest.fn(); const onClose = vitest.fn(); @@ -618,7 +625,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("can call socket methods", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("can call socket methods", async () => { wss.once("connection", (ws) => { ws.on("message", (data) => { ws.send(data); // Echo back @@ -667,7 +675,8 @@ describe.skip("usePartySocket", () => { expect(result.current.readyState).toBe(WebSocket.CLOSED); }); - test("connects when enabled is true (default)", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("connects when enabled is true (default)", async () => { wss.once("connection", (ws) => { ws.close(); }); @@ -690,7 +699,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("disconnects when enabled changes from true to false", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("disconnects when enabled changes from true to false", async () => { wss.once("connection", (ws) => { // Keep connection open }); @@ -722,7 +732,8 @@ describe.skip("usePartySocket", () => { ); }); - test("reconnects when enabled changes from false to true", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("reconnects when enabled changes from false to true", async () => { wss.once("connection", (ws) => { // Keep connection open }); @@ -751,7 +762,8 @@ describe.skip("usePartySocket", () => { result.current.close(); }); - test("keeps the same socket instance when enabled toggles", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("keeps the same socket instance when enabled toggles", async () => { wss.once("connection", () => { // Keep connection open }); @@ -791,7 +803,7 @@ describe.skip("usePartySocket", () => { }); }); -describe.skip("useWebSocket", () => { +describe.skipIf(!!process.env.GITHUB_ACTIONS)("useWebSocket", () => { let wss: WebSocketServer; beforeAll(() => { @@ -983,7 +995,8 @@ describe.skip("useWebSocket", () => { expect(result.current.readyState).toBe(WebSocket.CLOSED); }); - test("connects when enabled is true (default)", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("connects when enabled is true (default)", async () => { wss.once("connection", (ws) => { ws.close(); }); @@ -1004,7 +1017,8 @@ describe.skip("useWebSocket", () => { result.current.close(); }); - test("disconnects when enabled changes from true to false", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("disconnects when enabled changes from true to false", async () => { wss.once("connection", () => { // Keep connection open }); @@ -1034,7 +1048,8 @@ describe.skip("useWebSocket", () => { ); }); - test("reconnects when enabled changes from false to true", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("reconnects when enabled changes from false to true", async () => { wss.once("connection", () => { // Keep connection open }); @@ -1061,7 +1076,8 @@ describe.skip("useWebSocket", () => { result.current.close(); }); - test("keeps the same socket instance when enabled toggles", async () => { + // TODO: flaky — WebSocket connection timing in jsdom is unreliable + test.skip("keeps the same socket instance when enabled toggles", async () => { wss.once("connection", () => { // Keep connection open }); diff --git a/packages/partysocket/src/tests/react-ssr.test.tsx b/packages/partysocket/src/tests/react-ssr.test.tsx index d608b3b..921d035 100644 --- a/packages/partysocket/src/tests/react-ssr.test.tsx +++ b/packages/partysocket/src/tests/react-ssr.test.tsx @@ -2,432 +2,454 @@ * @vitest-environment node */ import { renderToString } from "react-dom/server"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from "vitest"; import { type WebSocket as NodeWebSocket, WebSocketServer } from "ws"; import { usePartySocket, useWebSocket } from "../react"; -const PORT = 50135; +const PORT = 50140; // Mock window object for SSR tests const originalWindow = global.window; const originalDocument = global.document; -describe.skip("SSR/Node.js Environment - usePartySocket", () => { - let wss: WebSocketServer; - - beforeEach(() => { - wss = new WebSocketServer({ port: PORT }); - // Clean up globals - // @ts-expect-error - we're testing undefined window - delete global.window; - // @ts-expect-error - we're testing undefined document - delete global.document; - }); - - afterEach(() => { - return new Promise((resolve) => { - wss.clients.forEach((client: NodeWebSocket) => { - client.terminate(); - }); - wss.close(() => { - global.window = originalWindow; - global.document = originalDocument; - resolve(); +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "SSR/Node.js Environment - usePartySocket", + () => { + let wss: WebSocketServer; + + beforeEach(() => { + wss = new WebSocketServer({ port: PORT }); + // Clean up globals + // @ts-expect-error - we're testing undefined window + delete global.window; + // @ts-expect-error - we're testing undefined document + delete global.document; + }); + + afterEach(() => { + return new Promise((resolve) => { + wss.clients.forEach((client: NodeWebSocket) => { + client.terminate(); + }); + wss.close(() => { + global.window = originalWindow; + global.document = originalDocument; + resolve(); + }); }); }); - }); - it("should use default host when window is not available", () => { - expect(global.window).toBeUndefined(); + it("should use default host when window is not available", () => { + expect(global.window).toBeUndefined(); - function TestComponent() { - const socket = usePartySocket({ - room: "test-room", - startClosed: true - }); + function TestComponent() { + const socket = usePartySocket({ + room: "test-room", + startClosed: true + }); - return
Host: {socket.host}
; - } + return
Host: {socket.host}
; + } - const html = renderToString(); - expect(html).toContain("dummy-domain.com"); - }); + const html = renderToString(); + expect(html).toContain("dummy-domain.com"); + }); - it("should not attempt to connect during SSR when startClosed is true", () => { - const onOpen = vi.fn(); - const onError = vi.fn(); + it("should not attempt to connect during SSR when startClosed is true", () => { + const onOpen = vi.fn(); + const onError = vi.fn(); - function TestComponent() { - usePartySocket({ - room: "test-room", - startClosed: true, - onOpen, - onError - }); + function TestComponent() { + usePartySocket({ + room: "test-room", + startClosed: true, + onOpen, + onError + }); - return
Rendered
; - } - - const html = renderToString(); - expect(html).toContain("Rendered"); - expect(onOpen).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); - - it("should handle explicit host in SSR environment", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "custom-host.com", - room: "test-room", - startClosed: true - }); + return
Rendered
; + } - return
Host: {socket.host}
; - } + const html = renderToString(); + expect(html).toContain("Rendered"); + expect(onOpen).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); - const html = renderToString(); - expect(html).toContain("custom-host.com"); - }); + it("should handle explicit host in SSR environment", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "custom-host.com", + room: "test-room", + startClosed: true + }); - it("should create socket with correct protocol in SSR", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - protocol: "wss", - startClosed: true - }); + return
Host: {socket.host}
; + } - return
URL: {socket.roomUrl}
; - } + const html = renderToString(); + expect(html).toContain("custom-host.com"); + }); - const html = renderToString(); - expect(html).toContain("wss://example.com"); - }); + it("should create socket with correct protocol in SSR", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + protocol: "wss", + startClosed: true + }); - it("should handle party option in SSR", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - party: "custom-party", - startClosed: true - }); + return
URL: {socket.roomUrl}
; + } - return
Party: {socket.name}
; - } + const html = renderToString(); + expect(html).toContain("wss://example.com"); + }); - const html = renderToString(); - expect(html).toContain("custom-party"); - }); + it("should handle party option in SSR", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + party: "custom-party", + startClosed: true + }); - it("should generate UUID for client id in SSR", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - startClosed: true - }); + return
Party: {socket.name}
; + } - return
ID: {socket.id}
; - } - - const html = renderToString(); - // Should have generated a UUID (36 characters with hyphens) - // React adds comments in SSR output - expect(html).toMatch( - /ID:.*[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/ - ); - }); - - it("should preserve custom id in SSR", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - id: "custom-client-id", - startClosed: true - }); + const html = renderToString(); + expect(html).toContain("custom-party"); + }); - return
ID: {socket.id}
; - } + it("should generate UUID for client id in SSR", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + startClosed: true + }); - const html = renderToString(); - expect(html).toContain("custom-client-id"); - }); + return
ID: {socket.id}
; + } - it("should handle query params in SSR", () => { - function TestComponent() { - const _socket = usePartySocket({ - host: "example.com", - room: "test-room", - query: { token: "abc123" }, - startClosed: true - }); + const html = renderToString(); + // Should have generated a UUID (36 characters with hyphens) + // React adds comments in SSR output + expect(html).toMatch( + /ID:.*[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/ + ); + }); - return
Created
; - } + it("should preserve custom id in SSR", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + id: "custom-client-id", + startClosed: true + }); - const html = renderToString(); - expect(html).toContain("Created"); - }); + return
ID: {socket.id}
; + } - it("should handle async query params in SSR", () => { - function TestComponent() { - const _socket = usePartySocket({ - host: "example.com", - room: "test-room", - query: async () => ({ token: "abc123" }), - startClosed: true - }); + const html = renderToString(); + expect(html).toContain("custom-client-id"); + }); - return
Created
; - } + it("should handle query params in SSR", () => { + function TestComponent() { + const _socket = usePartySocket({ + host: "example.com", + room: "test-room", + query: { token: "abc123" }, + startClosed: true + }); - const html = renderToString(); - expect(html).toContain("Created"); - }); + return
Created
; + } - it("should not throw when WebSocket constructor is missing", () => { - // Save WebSocket - const originalWebSocket = global.WebSocket; - // @ts-expect-error - testing missing WebSocket - delete global.WebSocket; + const html = renderToString(); + expect(html).toContain("Created"); + }); - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - startClosed: true - }); + it("should handle async query params in SSR", () => { + function TestComponent() { + const _socket = usePartySocket({ + host: "example.com", + room: "test-room", + query: async () => ({ token: "abc123" }), + startClosed: true + }); - return
Rendered: {socket.readyState}
; - } + return
Created
; + } - expect(() => { const html = renderToString(); - expect(html).toContain("Rendered"); - }).not.toThrow(); - - // Restore - global.WebSocket = originalWebSocket; - }); -}); - -describe.skip("SSR/Node.js Environment - useWebSocket", () => { - let wss: WebSocketServer; - - beforeEach(() => { - wss = new WebSocketServer({ port: PORT + 1 }); - // @ts-expect-error - we're testing undefined window - delete global.window; - // @ts-expect-error - we're testing undefined document - delete global.document; - }); - - afterEach(() => { - return new Promise((resolve) => { - wss.clients.forEach((client: NodeWebSocket) => { - client.terminate(); - }); - wss.close(() => { - global.window = originalWindow; - global.document = originalDocument; - resolve(); - }); + expect(html).toContain("Created"); + }); + + it("should not throw when WebSocket constructor is missing", () => { + // Save WebSocket + const originalWebSocket = global.WebSocket; + // @ts-expect-error - testing missing WebSocket + delete global.WebSocket; + + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + startClosed: true + }); + + return
Rendered: {socket.readyState}
; + } + + expect(() => { + const html = renderToString(); + expect(html).toContain("Rendered"); + }).not.toThrow(); + + // Restore + global.WebSocket = originalWebSocket; + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "SSR/Node.js Environment - useWebSocket", + () => { + let wss: WebSocketServer; + + beforeAll(() => { + wss = new WebSocketServer({ port: PORT + 1 }); }); - }); - it("should render with string URL in SSR", () => { - function TestComponent() { - const socket = useWebSocket(`ws://localhost:${PORT + 1}`, undefined, { - startClosed: true + afterAll(() => { + return new Promise((resolve) => { + wss.clients.forEach((client: NodeWebSocket) => { + client.terminate(); + }); + wss.close(() => resolve()); }); + }); - return
State: {socket.readyState}
; - } + beforeEach(() => { + // @ts-expect-error - we're testing undefined window + delete global.window; + // @ts-expect-error - we're testing undefined document + delete global.document; + }); - const html = renderToString(); - expect(html).toContain("State:"); - }); + afterEach(() => { + global.window = originalWindow; + global.document = originalDocument; + }); - it("should render with function URL in SSR", () => { - function TestComponent() { - const socket = useWebSocket( - () => `ws://localhost:${PORT + 1}`, - undefined, - { + it("should render with string URL in SSR", () => { + function TestComponent() { + const socket = useWebSocket(`ws://localhost:${PORT + 1}`, undefined, { startClosed: true - } - ); + }); - return
State: {socket.readyState}
; - } + return
State: {socket.readyState}
; + } - const html = renderToString(); - expect(html).toContain("State:"); - }); + const html = renderToString(); + expect(html).toContain("State:"); + }); - it("should render with async URL in SSR", () => { - function TestComponent() { - const socket = useWebSocket( - async () => `ws://localhost:${PORT + 1}`, - undefined, - { - startClosed: true - } - ); + it("should render with function URL in SSR", () => { + function TestComponent() { + const socket = useWebSocket( + () => `ws://localhost:${PORT + 1}`, + undefined, + { + startClosed: true + } + ); - return
State: {socket.readyState}
; - } + return
State: {socket.readyState}
; + } - const html = renderToString(); - expect(html).toContain("State:"); - }); + const html = renderToString(); + expect(html).toContain("State:"); + }); - it("should handle protocols array in SSR", () => { - function TestComponent() { - const _socket = useWebSocket( - `ws://localhost:${PORT + 1}`, - ["protocol1", "protocol2"], - { - startClosed: true - } - ); + it("should render with async URL in SSR", () => { + function TestComponent() { + const socket = useWebSocket( + async () => `ws://localhost:${PORT + 1}`, + undefined, + { + startClosed: true + } + ); + + return
State: {socket.readyState}
; + } - return
Rendered
; - } + const html = renderToString(); + expect(html).toContain("State:"); + }); - const html = renderToString(); - expect(html).toContain("Rendered"); - }); + it("should handle protocols array in SSR", () => { + function TestComponent() { + const _socket = useWebSocket( + `ws://localhost:${PORT + 1}`, + ["protocol1", "protocol2"], + { + startClosed: true + } + ); - it("should handle protocol function in SSR", () => { - function TestComponent() { - const _socket = useWebSocket( - `ws://localhost:${PORT + 1}`, - () => "protocol1", - { - startClosed: true - } - ); + return
Rendered
; + } - return
Rendered
; - } + const html = renderToString(); + expect(html).toContain("Rendered"); + }); - const html = renderToString(); - expect(html).toContain("Rendered"); - }); + it("should handle protocol function in SSR", () => { + function TestComponent() { + const _socket = useWebSocket( + `ws://localhost:${PORT + 1}`, + () => "protocol1", + { + startClosed: true + } + ); - it("should handle async protocol in SSR", () => { - function TestComponent() { - const _socket = useWebSocket( - `ws://localhost:${PORT + 1}`, - async () => "protocol1", - { - startClosed: true - } - ); + return
Rendered
; + } + + const html = renderToString(); + expect(html).toContain("Rendered"); + }); - return
Rendered
; - } + it("should handle async protocol in SSR", () => { + function TestComponent() { + const _socket = useWebSocket( + `ws://localhost:${PORT + 1}`, + async () => "protocol1", + { + startClosed: true + } + ); - const html = renderToString(); - expect(html).toContain("Rendered"); - }); + return
Rendered
; + } - it("should not connect during SSR rendering", () => { - const onOpen = vi.fn(); - const onMessage = vi.fn(); - const onError = vi.fn(); + const html = renderToString(); + expect(html).toContain("Rendered"); + }); - function TestComponent() { - useWebSocket(`ws://localhost:${PORT + 1}`, undefined, { - startClosed: true, - onOpen, - onMessage, - onError - }); + it("should not connect during SSR rendering", () => { + const onOpen = vi.fn(); + const onMessage = vi.fn(); + const onError = vi.fn(); - return
Rendered
; - } - - const html = renderToString(); - expect(html).toContain("Rendered"); - expect(onOpen).not.toHaveBeenCalled(); - expect(onMessage).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); -}); - -describe.skip("SSR/Node.js Environment - Hydration Safety", () => { - beforeEach(() => { - // @ts-expect-error - we're testing undefined window - delete global.window; - // @ts-expect-error - we're testing undefined document - delete global.document; - }); - - afterEach(() => { - global.window = originalWindow; - global.document = originalDocument; - }); - - it("should create consistent socket IDs across renders", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - id: "stable-id", - startClosed: true - }); + function TestComponent() { + useWebSocket(`ws://localhost:${PORT + 1}`, undefined, { + startClosed: true, + onOpen, + onMessage, + onError + }); - return
ID: {socket.id}
; - } + return
Rendered
; + } - const html1 = renderToString(); - const html2 = renderToString(); + const html = renderToString(); + expect(html).toContain("Rendered"); + expect(onOpen).not.toHaveBeenCalled(); + expect(onMessage).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + } +); + +describe.skipIf(!!process.env.GITHUB_ACTIONS)( + "SSR/Node.js Environment - Hydration Safety", + () => { + beforeEach(() => { + // @ts-expect-error - we're testing undefined window + delete global.window; + // @ts-expect-error - we're testing undefined document + delete global.document; + }); - expect(html1).toBe(html2); - }); + afterEach(() => { + global.window = originalWindow; + global.document = originalDocument; + }); - it("should create consistent URLs across renders", () => { - function TestComponent() { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - id: "stable-id", - query: { token: "abc" }, - startClosed: true - }); + it("should create consistent socket IDs across renders", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + id: "stable-id", + startClosed: true + }); + + return
ID: {socket.id}
; + } - return
URL: {socket.roomUrl}
; - } + const html1 = renderToString(); + const html2 = renderToString(); - const html1 = renderToString(); - const html2 = renderToString(); + expect(html1).toBe(html2); + }); - expect(html1).toBe(html2); - }); + it("should create consistent URLs across renders", () => { + function TestComponent() { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + id: "stable-id", + query: { token: "abc" }, + startClosed: true + }); - it("should handle changing query params in SSR", () => { - function TestComponent({ token }: { token: string }) { - const socket = usePartySocket({ - host: "example.com", - room: "test-room", - id: "stable-id", - query: { token }, - startClosed: true - }); + return
URL: {socket.roomUrl}
; + } - return
URL: {socket.roomUrl}
; - } + const html1 = renderToString(); + const html2 = renderToString(); - const html1 = renderToString(); - const html2 = renderToString(); + expect(html1).toBe(html2); + }); + + it("should handle changing query params in SSR", () => { + function TestComponent({ token }: { token: string }) { + const socket = usePartySocket({ + host: "example.com", + room: "test-room", + id: "stable-id", + query: { token }, + startClosed: true + }); - // Base URL should be the same - expect(html1).toContain("wss://example.com/parties/main/test-room"); - expect(html2).toContain("wss://example.com/parties/main/test-room"); - }); -}); + return
URL: {socket.roomUrl}
; + } + + const html1 = renderToString(); + const html2 = renderToString(); + + // Base URL should be the same + expect(html1).toContain("wss://example.com/parties/main/test-room"); + expect(html2).toContain("wss://example.com/parties/main/test-room"); + }); + } +);