Files
upstream-tldraw/packages/sync-core/src/lib/ClientWebSocketAdapter.test.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

1002 lines
35 KiB
TypeScript

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<typeof import('@tldraw/utils')>()
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<TLRecord> {
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<typeof vi.spyOn>
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<void>((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<string>((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<CloseEvent> 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<CloseEvent> 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<CloseEvent> 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<CloseEvent> 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<CloseEvent> 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 <n>_ 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<CloseEvent> 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<typeof vi.spyOn>
beforeEach(async () => {
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
wsServer = new WebSocketServer({ port: 0 })
wsServer.on('connection', connectMock as any)
await new Promise<void>((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<string>()
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<string> = 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<void>
}
await expect(manager.scheduleAttempt()).rejects.toThrow(error)
expect(consoleError).not.toHaveBeenCalled()
} finally {
testAdapter.close()
consoleError.mockRestore()
}
})
})