import { promiseWithResolve, warnOnce } from '@tldraw/utils' import { TLRecord, sleep } from 'tldraw' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // spy on warnOnce so the CW5 warning can be observed deterministically: the real // warnOnce dedupes globally, so incidental 1006 closes elsewhere in the suite would // otherwise consume the one-time warning before the test that asserts it runs vi.mock('@tldraw/utils', async (importOriginal) => { const actual = await importOriginal() return { ...actual, warnOnce: vi.fn(actual.warnOnce) } }) // NOTE: setupVitest.js replaces the global WebSocket with the 'ws' package's WebSocket, // matching the WebSocketServer the tests connect to. import { WebSocketServer, WebSocket as WsWebSocket } from 'ws' import { MAX_ASSEMBLED_MESSAGE_CHARS } from './chunk' import { ACTIVE_MAX_DELAY, ACTIVE_MIN_DELAY, ATTEMPT_TIMEOUT, ClientWebSocketAdapter, DELAY_EXPONENT, INACTIVE_MAX_DELAY, INACTIVE_MIN_DELAY, } from './ClientWebSocketAdapter' import { TLSocketClientSentEvent, getTlsyncProtocolVersion } from './protocol' import { TLSyncErrorCloseEventCode, TLSyncErrorCloseEventReason } from './TLSyncClient' async function waitFor(predicate: () => boolean) { let safety = 0 while (!predicate()) { if (safety++ > 1000) { throw new Error('waitFor predicate timed out') } try { vi.runAllTimers() vi.useRealTimers() await sleep(10) } finally { vi.useFakeTimers() } } } // A minimal fake socket that satisfies the parts of the WebSocket interface // that ClientWebSocketAdapter._setNewSocket touches. function mockSocket(readyState: number = WebSocket.CONNECTING) { return { readyState, onopen: null, onclose: null, onerror: null, onmessage: null, close: vi.fn(), } as any } function connectMessage(): TLSocketClientSentEvent { return { type: 'connect', connectRequestId: 'test', schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} }, protocolVersion: getTlsyncProtocolVersion(), lastServerClock: 0, } } vi.useFakeTimers() describe('ClientWebSocketAdapter', () => { let adapter: ClientWebSocketAdapter let wsServer: WebSocketServer let connectedServerSocket: WsWebSocket const connectMock = vi.fn((socket: WsWebSocket) => { connectedServerSocket = socket }) // Bound per test on an OS-assigned port. A fixed port raced against the previous test's // server, which closes asynchronously, and intermittently failed with EADDRINUSE. let port: number let consoleWarnSpy: ReturnType beforeEach(async () => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) wsServer = new WebSocketServer({ port: 0 }) // Attach before the adapter exists: the server is already listening by the time the // adapter is constructed, so a handler added afterwards can miss the first connection. wsServer.on('connection', connectMock as any) await new Promise((resolve, reject) => { wsServer.once('listening', resolve) wsServer.once('error', reject) }) port = (wsServer.address() as { port: number }).port adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) }) afterEach(() => { consoleWarnSpy.mockRestore() adapter.close() // Not awaited: the next test binds its own OS-assigned port, so a server still closing // here can't collide with it. Awaiting would deliver in-flight messages to the socket // adapter.close() just orphaned. wsServer.close() connectMock.mockClear() }) describe('connection and initial state (CW1, CW2)', () => { it('[CW2] starts with connectionStatus offline while the internal status is initial', () => { const newAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) try { expect(newAdapter._connectionStatus.get()).toBe('initial') expect(newAdapter.connectionStatus).toBe('offline') } finally { newAdapter.close() } }) it('[CW2] becomes online when the socket opens and notifies status listeners', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(adapter.connectionStatus).toBe('online') expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) }) it('[CW1] connects using the URI returned by getUri and re-invokes it for every attempt', async () => { let uriCallCount = 0 const dynamicAdapter = new ClientWebSocketAdapter(() => { uriCallCount++ return `ws://localhost:${port}?attempt=${uriCallCount}` }) try { await waitFor(() => dynamicAdapter._ws?.readyState === WebSocket.OPEN) expect(uriCallCount).toBeGreaterThanOrEqual(1) const countAfterFirstConnect = uriCallCount // force a reconnection: getUri must be consulted again (e.g. for fresh auth tokens) dynamicAdapter.restart() await waitFor( () => dynamicAdapter._ws?.readyState === WebSocket.OPEN && uriCallCount > countAfterFirstConnect ) expect(uriCallCount).toBeGreaterThan(countAfterFirstConnect) } finally { dynamicAdapter.close() } }) it('[CW1] supports an async getUri', async () => { let resolveUri: (uri: string) => void const uriPromise = new Promise((resolve) => { resolveUri = resolve }) const asyncAdapter = new ClientWebSocketAdapter(() => uriPromise) try { // no socket can be created until the promise resolves expect(asyncAdapter._ws).toBeNull() resolveUri!(`ws://localhost:${port}`) await waitFor(() => asyncAdapter._ws?.readyState === WebSocket.OPEN) expect(asyncAdapter.connectionStatus).toBe('online') } finally { asyncAdapter.close() } }) }) describe('close codes and status changes (CW3, CW4, CW5)', () => { it('[CW3] a close with code 4099 produces status error carrying the close reason', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._ws!.onclose?.({ code: TLSyncErrorCloseEventCode, reason: TLSyncErrorCloseEventReason.NOT_FOUND, } satisfies Partial as any) expect(onStatusChange).toHaveBeenCalledWith({ status: 'error', reason: TLSyncErrorCloseEventReason.NOT_FOUND, }) expect(adapter.connectionStatus).toBe('error') }) it('[CW3] a close with code 4099 and an empty reason reports UNKNOWN_ERROR', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._ws!.onclose?.({ code: TLSyncErrorCloseEventCode, reason: '', } satisfies Partial as any) expect(onStatusChange).toHaveBeenCalledWith({ status: 'error', reason: TLSyncErrorCloseEventReason.UNKNOWN_ERROR, }) }) it('[CW3] a close with any other code produces offline', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._ws!.onclose?.({ code: 1000, reason: 'Normal closure', } satisfies Partial as any) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) expect(adapter.connectionStatus).toBe('offline') }) it('[CW3] a socket error produces offline', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._ws?.onerror?.({} as any) expect(adapter.connectionStatus).toBe('offline') }) it('[CW3] the server dropping the connection produces offline', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(adapter.connectionStatus).toBe('offline') }) it('[CW2][CW3] notifies status listeners across repeated connection cycles', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) adapter._ws?.onerror?.({} as any) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) }) it('[CW4] does not re-notify listeners when the status does not change', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) onStatusChange.mockClear() // a second disconnect-flavoured event while already offline is not re-notified adapter._ws!.onerror?.({} as any) expect(onStatusChange).not.toHaveBeenCalled() expect(adapter.connectionStatus).toBe('offline') }) it('[CW4] suppresses an error close that arrives while already offline', async () => { const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) onStatusChange.mockClear() adapter._ws!.onclose?.({ code: TLSyncErrorCloseEventCode, reason: TLSyncErrorCloseEventReason.NOT_FOUND, } satisfies Partial as any) expect(onStatusChange).not.toHaveBeenCalled() expect(adapter.connectionStatus).toBe('offline') }) it('[CW5] a close with code 1006 on a socket that never opened logs a one-time warning', async () => { const warnOnceMock = vi.mocked(warnOnce) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._closeSocket() warnOnceMock.mockClear() const fake = mockSocket() adapter._setNewSocket(fake) fake.onclose?.({ code: 1006, reason: '' }) // the warning goes through warnOnce, which dedupes it for the app's lifetime expect(warnOnceMock).toHaveBeenCalledTimes(1) expect(warnOnceMock).toHaveBeenCalledWith( expect.stringContaining('Could not open WebSocket connection') ) }) it('[CW5] a close with code 1006 on a socket that did open does not warn', async () => { const warnOnceMock = vi.mocked(warnOnce) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) warnOnceMock.mockClear() adapter._ws!.onclose?.({ code: 1006, reason: '' } satisfies Partial as any) expect(warnOnceMock).not.toHaveBeenCalled() }) }) describe('URI conversion (CW1)', () => { it('[CW1] converts http URIs to ws and connects', async () => { const httpAdapter = new ClientWebSocketAdapter(() => `http://localhost:${port}`) try { await waitFor(() => httpAdapter._ws?.readyState === WebSocket.OPEN) expect(httpAdapter._ws!.url).toMatch(/^ws:\/\//) expect(httpAdapter.connectionStatus).toBe('online') } finally { httpAdapter.close() } }) it('[CW1] converts https URIs to wss', async () => { const httpsAdapter = new ClientWebSocketAdapter(() => `https://localhost:${port}`) try { await waitFor(() => httpsAdapter._ws !== null) expect(httpsAdapter._ws!.url).toMatch(/^wss:\/\//) } finally { httpsAdapter.close() } }) }) describe('sending messages (CW6)', () => { it('[CW6] JSON-stringifies and sends messages when online', async () => { const onMessage = vi.fn() connectMock.mockImplementationOnce((ws: any) => { ws.on('message', onMessage) }) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const message = connectMessage() adapter.sendMessage(message) await waitFor(() => onMessage.mock.calls.length === 1) expect(JSON.parse(onMessage.mock.calls[0][0].toString())).toEqual(message) }) it('[CW6] chunks large messages when sending', async () => { const onMessage = vi.fn() connectMock.mockImplementationOnce((ws: any) => { ws.on('message', onMessage) }) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) // large enough to exceed the default max chunk size (256k chars) const message = { ...connectMessage(), largeData: 'x'.repeat(300000) } as any adapter.sendMessage(message) const getParts = () => onMessage.mock.calls.map((call) => call[0].toString()) const reassemble = (parts: string[]) => parts.map((p) => p.replace(/^\d+_/, '')).join('') await waitFor(() => { const parts = getParts() if (parts.length < 2) return false try { JSON.parse(reassemble(parts)) return true } catch { return false } }) const parts = getParts() expect(parts.length).toBeGreaterThan(1) // each chunk carries the _ countdown prefix expect(parts[0]).toMatch(/^\d+_/) expect(JSON.parse(reassemble(parts))).toEqual(message) }) it('[CW6] warns and drops the message when the socket exists but is not online', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) // synthetic close: the adapter goes offline but keeps its socket reference adapter._ws!.onclose?.({ code: 1000, reason: '' } satisfies Partial as any) expect(adapter.connectionStatus).toBe('offline') consoleWarnSpy.mockClear() adapter.sendMessage(connectMessage()) expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining('Tried to send message while') ) }) it('[CW6] refuses to send a message the server would reject as too large', async () => { const onMessage = vi.fn() connectMock.mockImplementationOnce((ws: any) => { ws.on('message', onMessage) }) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) const message = { ...connectMessage(), largeData: 'x'.repeat(MAX_ASSEMBLED_MESSAGE_CHARS), } as any adapter.sendMessage(message) // nothing goes on the wire: sending would only earn a close and a reconnect that // re-sends the same message expect(onMessage).not.toHaveBeenCalled() expect(adapter.connectionStatus).toBe('error') expect(onStatusChange).toHaveBeenCalledWith({ status: 'error', reason: TLSyncErrorCloseEventReason.MESSAGE_TOO_LARGE, }) }) it('[CW6] silently drops the message when there is no socket', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._closeSocket() expect(adapter._ws).toBeNull() consoleWarnSpy.mockClear() expect(() => adapter.sendMessage(connectMessage())).not.toThrow() expect(consoleWarnSpy).not.toHaveBeenCalled() }) }) describe('receiving messages (CW7)', () => { it('[CW7] parses incoming JSON and forwards it to message listeners', async () => { const onMessage = vi.fn() adapter.onReceiveMessage(onMessage) connectMock.mockImplementationOnce((ws: any) => { ws.send('{ "type": "message", "data": "hello" }') }) await waitFor(() => onMessage.mock.calls.length === 1) expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' }) }) it('[CW7] restarts the connection on a malformed JSON message instead of forwarding it', async () => { const warnOnceMock = vi.mocked(warnOnce) const onMessage = vi.fn() const onStatusChange = vi.fn() adapter.onReceiveMessage(onMessage) adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const prevWs = adapter._ws warnOnceMock.mockClear() connectedServerSocket.send('{ "type": "message",') // the malformed message is never forwarded to listeners, and the connection restarts so // the client re-hydrates from the last known server clock rather than silently desyncing await waitFor(() => adapter._ws !== prevWs && adapter._ws?.readyState === WebSocket.OPEN) expect(onMessage).not.toHaveBeenCalled() expect(warnOnceMock).toHaveBeenCalledWith( 'Received malformed WebSocket message. Restarting the connection.' ) expect(onStatusChange).toHaveBeenCalledWith({ status: 'offline' }) expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) expect(adapter.connectionStatus).toBe('online') // the restarted connection is fully functional: a well-formed message on the new socket // is delivered to listeners as usual connectedServerSocket.send('{ "type": "message", "data": "hello" }') await waitFor(() => onMessage.mock.calls.length === 1) expect(onMessage).toHaveBeenCalledWith({ type: 'message', data: 'hello' }) }) it('[CW7] stops delivering messages after unsubscribe', async () => { const onMessage = vi.fn() const unsubscribe = adapter.onReceiveMessage(onMessage) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) connectedServerSocket.send('{ "type": "test", "data": "first" }') await waitFor(() => onMessage.mock.calls.length === 1) expect(onMessage).toHaveBeenCalledWith({ type: 'test', data: 'first' }) unsubscribe() onMessage.mockClear() connectedServerSocket.send('{ "type": "test", "data": "second" }') vi.advanceTimersByTime(200) expect(onMessage).not.toHaveBeenCalled() }) it('[CW2] stops notifying status listeners after unsubscribe', async () => { const onStatusChange = vi.fn() const unsubscribe = adapter.onStatusChange(onStatusChange) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(onStatusChange).toHaveBeenCalledWith({ status: 'online' }) unsubscribe() onStatusChange.mockClear() connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(onStatusChange).not.toHaveBeenCalled() }) }) describe('restart (CW8)', () => { it('[CW8] restart closes the current socket and reconnects, notifying offline then online', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const prevWs = adapter._ws const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) adapter.restart() await waitFor(() => onStatusChange.mock.calls.length === 2) expect(onStatusChange.mock.calls[0][0]).toEqual({ status: 'offline' }) expect(onStatusChange.mock.calls[1][0]).toEqual({ status: 'online' }) expect(adapter._ws).not.toBe(prevWs) }) }) describe('disposal (CW9)', () => { it('[CW9] close closes the underlying socket', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const closeSpy = vi.spyOn(adapter._ws!, 'close') adapter.close() expect(closeSpy).toHaveBeenCalled() }) it('[CW6][CW9] sendMessage, restart, and listener registration throw after close', () => { adapter.close() expect(() => { adapter.sendMessage({} as any) }).toThrow('Tried to send message on a disposed socket') expect(() => { adapter.onReceiveMessage(() => {}) }).toThrow('Tried to add message listener on a disposed socket') expect(() => { adapter.onStatusChange(() => {}) }).toThrow('Tried to add status listener on a disposed socket') expect(() => { adapter.restart() }).toThrow('Tried to restart a disposed socket') }) it('[CW9] close is idempotent', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter.close() expect(() => adapter.close()).not.toThrow() }) it('[CW9][RM5] closing immediately does not start another getUri call', async () => { const getUri = vi.fn(() => `ws://localhost:${port}`) const testAdapter = new ClientWebSocketAdapter(getUri) testAdapter.close() const callsAtClose = getUri.mock.calls.length await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY) expect(getUri).toHaveBeenCalledTimes(callsAtClose) expect(testAdapter._ws).toBeNull() }) it('[CW9][RM5] close reports offline without notifying listeners or reconnecting', async () => { let uriCallCount = 0 const testAdapter = new ClientWebSocketAdapter(() => { uriCallCount++ return `ws://localhost:${port}` }) const onStatusChange = vi.fn() testAdapter.onStatusChange(onStatusChange) await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN) const callsBeforeClose = uriCallCount onStatusChange.mockClear() testAdapter.close() expect(testAdapter.connectionStatus).toBe('offline') // let the socket's own close event land, then run out any (leaked) reconnect timer vi.useRealTimers() await sleep(50) vi.useFakeTimers() vi.advanceTimersByTime(INACTIVE_MAX_DELAY) vi.useRealTimers() await sleep(20) vi.useFakeTimers() expect(onStatusChange).not.toHaveBeenCalled() expect(uriCallCount).toBe(callsBeforeClose) expect(testAdapter._ws).toBeNull() }) }) describe('orphaned sockets (CW10)', () => { it('[CW10] ignores events from orphaned sockets', async () => { const onStatusChange = vi.fn() const onMessage = vi.fn() adapter.onStatusChange(onStatusChange) adapter.onReceiveMessage(onMessage) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const originalSocket = adapter._ws! // create a new connection, orphaning the old socket adapter._closeSocket() await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) onStatusChange.mockClear() onMessage.mockClear() // events on the orphaned socket must not change the adapter's status originalSocket.onclose?.({ code: 1000, reason: 'test' } as CloseEvent) originalSocket.onerror?.({} as Event) // (onmessage on an orphaned socket asserts by design, so it is not triggered here) expect(onStatusChange).not.toHaveBeenCalled() expect(onMessage).not.toHaveBeenCalled() expect(adapter.connectionStatus).toBe('online') }) }) }) describe('ReconnectManager', () => { let adapter: ClientWebSocketAdapter let wsServer: WebSocketServer let connectedServerSocket: WsWebSocket const connectMock = vi.fn((socket: WsWebSocket) => { connectedServerSocket = socket }) // See the note on the other suite's port: bound per test on an OS-assigned port. let port: number let consoleWarnSpy: ReturnType beforeEach(async () => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) wsServer = new WebSocketServer({ port: 0 }) wsServer.on('connection', connectMock as any) await new Promise((resolve, reject) => { wsServer.once('listening', resolve) wsServer.once('error', reject) }) port = (wsServer.address() as { port: number }).port adapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) }) afterEach(() => { consoleWarnSpy.mockRestore() adapter.close() // Not awaited - see the note on the other suite's teardown. wsServer.close() connectMock.mockClear() }) it('[RM1] exposes the documented delay constants', () => { expect(ACTIVE_MIN_DELAY).toBe(500) expect(ACTIVE_MAX_DELAY).toBe(2000) expect(INACTIVE_MIN_DELAY).toBe(1000) expect(INACTIVE_MAX_DELAY).toBe(1000 * 60 * 5) expect(DELAY_EXPONENT).toBe(1.5) expect(ATTEMPT_TIMEOUT).toBe(1000) }) it('[RM1] reconnection delays back off exponentially, bounded by the active delays', () => { const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) try { const manager = testAdapter._reconnectManager // make every disconnected() call take the "attempt now" branch ;(manager as any).lastAttemptStart = Date.now() - 1_000_000 manager.intendedDelay = ACTIVE_MIN_DELAY manager.disconnected() expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT) manager.disconnected() expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT ** 2) manager.disconnected() expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY * DELAY_EXPONENT ** 3) // capped at the active maximum manager.disconnected() expect(manager.intendedDelay).toBe(ACTIVE_MAX_DELAY) manager.disconnected() expect(manager.intendedDelay).toBe(ACTIVE_MAX_DELAY) } finally { testAdapter.close() } }) it('[RM1] uses the inactive delay bounds when the tab is hidden', () => { const hiddenMock = vi.spyOn(document, 'hidden', 'get').mockReturnValue(true) const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) try { const manager = testAdapter._reconnectManager ;(manager as any).lastAttemptStart = Date.now() - 1_000_000 // the delay is clamped up to the inactive minimum before applying the exponent manager.intendedDelay = 0 manager.disconnected() expect(manager.intendedDelay).toBe(INACTIVE_MIN_DELAY * DELAY_EXPONENT) // and capped at the inactive maximum manager.intendedDelay = INACTIVE_MAX_DELAY manager.disconnected() expect(manager.intendedDelay).toBe(INACTIVE_MAX_DELAY) } finally { testAdapter.close() hiddenMock.mockRestore() } }) it('[RM1][RM2] reconnects with a new socket after the server drops the connection, repeatedly', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const prevClientSocket = adapter._ws const prevServerSocket = connectedServerSocket prevServerSocket.terminate() await waitFor(() => connectedServerSocket !== prevServerSocket) // there is a race here, the server could've opened a new socket already, but it hasn't // transitioned to OPEN yet, thus the second waitFor await waitFor(() => connectedServerSocket.readyState === WebSocket.OPEN) expect(adapter._ws).not.toBe(prevClientSocket) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(adapter.connectionStatus).toBe('online') // and again connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) expect(adapter.connectionStatus).toBe('offline') await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(adapter.connectionStatus).toBe('online') }) it('[RM2] a successful connection resets the intended delay to the active minimum', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const manager = adapter._reconnectManager // directly: connected() resets a backed-off delay manager.intendedDelay = ACTIVE_MAX_DELAY manager.connected() expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY) // and through a real reconnect cycle manager.intendedDelay = ACTIVE_MAX_DELAY connectedServerSocket.terminate() await waitFor(() => adapter._ws?.readyState === WebSocket.CLOSED) await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY) }) it('[RM3] the window offline event closes the active socket', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) window.dispatchEvent(new Event('offline')) expect(adapter._ws).toBeNull() expect(adapter.connectionStatus).toBe('offline') }) it('[RM4] the window online event resets the backoff for an immediate reconnect attempt', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) connectedServerSocket.close() await waitFor(() => adapter._ws?.readyState !== WebSocket.OPEN) // close server to prevent an automatic reconnection winning the race wsServer.close() const manager = adapter._reconnectManager manager.intendedDelay = ACTIVE_MAX_DELAY // a recent attempt keeps maybeReconnected in the "honour the min delay" branch ;(manager as any).lastAttemptStart = Date.now() window.dispatchEvent(new Event('online')) expect(manager.intendedDelay).toBe(ACTIVE_MIN_DELAY) }) it('[RM1][RM4] the document becoming visible triggers an early reconnect attempt', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const hiddenMock = vi.spyOn(document, 'hidden', 'get') hiddenMock.mockReturnValue(true) // it's necessary to close the socket, as otherwise the websocket might stay half-open connectedServerSocket.close() wsServer.close() await waitFor(() => adapter.connectionStatus === 'offline') expect(adapter._reconnectManager.intendedDelay).toBeGreaterThanOrEqual(INACTIVE_MIN_DELAY) hiddenMock.mockReturnValue(false) document.dispatchEvent(new Event('visibilitychange')) expect(adapter._reconnectManager.intendedDelay).toBeLessThan(INACTIVE_MIN_DELAY) hiddenMock.mockRestore() }) it('[RM4] a socket that is already OPEN is left alone', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const ws = adapter._ws const onStatusChange = vi.fn() adapter.onStatusChange(onStatusChange) adapter._reconnectManager.maybeReconnected() expect(adapter._ws).toBe(ws) expect(adapter._ws!.readyState).toBe(WebSocket.OPEN) expect(onStatusChange).not.toHaveBeenCalled() }) it('[RM4] a socket CONNECTING for less than ATTEMPT_TIMEOUT is left alone and rechecked later', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._closeSocket() let now = Date.now() const dateSpy = vi.spyOn(Date, 'now').mockImplementation(() => now) try { const fake = mockSocket() adapter._setNewSocket(fake) ;(adapter._reconnectManager as any).lastAttemptStart = now adapter._reconnectManager.maybeReconnected() // young attempt: the socket is left alone expect(fake.close).not.toHaveBeenCalled() expect(adapter._ws).toBe(fake) // when the recheck fires after ATTEMPT_TIMEOUT, the still-CONNECTING socket // is closed and a new attempt is made now += ATTEMPT_TIMEOUT + 100 vi.advanceTimersByTime(ATTEMPT_TIMEOUT + 100) expect(fake.close).toHaveBeenCalled() expect(adapter._ws).toBeNull() } finally { dateSpy.mockRestore() } }) it('[RM4] a socket stuck in CONNECTING for longer than ATTEMPT_TIMEOUT is closed and retried', async () => { await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) adapter._closeSocket() const fake = mockSocket() adapter._setNewSocket(fake) ;(adapter._reconnectManager as any).lastAttemptStart = Date.now() - ATTEMPT_TIMEOUT - 100 adapter._reconnectManager.maybeReconnected() expect(fake.close).toHaveBeenCalled() expect(adapter._ws).toBeNull() }) it.each(['throws', 'rejects'])( '[RM1][CW1] getUri that %s is retried on the backoff instead of stranding the connection', async (failure) => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) let attempts = 0 const testAdapter = new ClientWebSocketAdapter(() => { attempts++ if (attempts < 3) { const error = new Error('token endpoint unavailable') if (failure === 'throws') throw error return Promise.reject(error) } return `ws://localhost:${port}` }) try { await waitFor(() => testAdapter._ws?.readyState === WebSocket.OPEN) expect(attempts).toBe(3) expect(consoleErrorSpy).toHaveBeenCalledTimes(2) expect(testAdapter.connectionStatus).toBe('online') } finally { testAdapter.close() consoleErrorSpy.mockRestore() } } ) it('[RM5] close cancels timers and removes the reconnect event listeners', () => { const testAdapter = new ClientWebSocketAdapter(() => `ws://localhost:${port}`) const manager = testAdapter._reconnectManager testAdapter.close() // with the listeners removed, reconnect hints no longer touch the manager manager.intendedDelay = 12345 window.dispatchEvent(new Event('online')) document.dispatchEvent(new Event('visibilitychange')) window.dispatchEvent(new Event('offline')) expect(manager.intendedDelay).toBe(12345) expect(testAdapter._ws).toBeNull() // closing again is safe expect(() => manager.close()).not.toThrow() }) it('[RM4] reconnect hints and the disconnect handler survive an undefined WebSocket global', async () => { // Regression test for the undefined-WebSocket crash guarded in ClientWebSocketAdapter.ts (#10106). await waitFor(() => adapter._ws?.readyState === WebSocket.OPEN) const manager = adapter._reconnectManager // jsdom reports a throwing event listener on window instead of from dispatchEvent const listenerErrors: unknown[] = [] const onError = (event: ErrorEvent) => { event.preventDefault() listenerErrors.push(event.error) } window.addEventListener('error', onError) const originalWebSocket = globalThis.WebSocket try { ;(globalThis as any).WebSocket = undefined window.dispatchEvent(new Event('online')) expect(() => manager.maybeReconnected()).not.toThrow() expect(() => manager.connected()).not.toThrow() expect(adapter.connectionStatus).toBe('online') window.dispatchEvent(new Event('offline')) expect(() => manager.disconnected()).not.toThrow() expect(listenerErrors).toEqual([]) expect(adapter.connectionStatus).toBe('offline') expect(adapter._ws).toBeNull() } finally { globalThis.WebSocket = originalWebSocket window.removeEventListener('error', onError) } }) }) describe('URI failure boundaries', () => { // These tests never open a real socket - the adapter is closed first, or the socket is a // mock - so the URI only has to parse. No server, hence no port to bind. const UNUSED_URI = 'ws://localhost:1' it.each(['resolves', 'rejects'])('ignores getUri that %s after close', async (outcome) => { const uri = promiseWithResolve() const getUri = vi.fn(() => uri) const testAdapter = new ClientWebSocketAdapter(getUri) const onStatusChange = vi.fn() testAdapter.onStatusChange(onStatusChange) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) try { await Promise.resolve() testAdapter.close() if (outcome === 'resolves') uri.resolve(UNUSED_URI) else uri.reject(new Error('token endpoint unavailable')) await vi.advanceTimersByTimeAsync(INACTIVE_MAX_DELAY) expect(getUri).toHaveBeenCalledTimes(1) expect(testAdapter._ws).toBeNull() expect(testAdapter.connectionStatus).toBe('offline') expect(onStatusChange).not.toHaveBeenCalled() expect(consoleError).not.toHaveBeenCalled() } finally { testAdapter.close() consoleError.mockRestore() } }) it.each([ { uri: 'not a URL', openSocket: false, error: 'Invalid URL' }, { uri: UNUSED_URI, openSocket: true, error: 'There should be no connection attempts while already connected', }, ])('does not treat $error as a getUri failure', async ({ uri, openSocket, error }) => { let nextUri: string | Promise = new Promise(() => {}) const testAdapter = new ClientWebSocketAdapter(() => nextUri) const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) try { await Promise.resolve() nextUri = uri if (openSocket) testAdapter._ws = mockSocket(WebSocket.OPEN) const manager = testAdapter._reconnectManager as unknown as { scheduleAttempt(): Promise } await expect(manager.scheduleAttempt()).rejects.toThrow(error) expect(consoleError).not.toHaveBeenCalled() } finally { testAdapter.close() consoleError.mockRestore() } }) })