// Connection-loss behaviour of the WS client, against a scripted fake socket. // Run: npm test (builds dist/ first, then `node --test test/`). import { test, beforeEach, afterEach } from 'node:test' import assert from 'node:assert/strict' import { createWsClient } from '../dist/index.js' // ── Fake WebSocket + tiny scripted server ────────────────────────────────── class FakeWS extends EventTarget { static CONNECTING = 0 static OPEN = 1 static CLOSING = 2 static CLOSED = 3 /** Per connection attempt: 'accept' | 'fail' | 'hang' (opens, never established). */ static plan = [] static sockets = [] /** event name → false to never answer it. */ static answer = {} constructor(url) { super() this.url = url this.readyState = FakeWS.CONNECTING this.sent = [] this.index = FakeWS.sockets.length FakeWS.sockets.push(this) const mode = FakeWS.plan[this.index] ?? 'accept' setTimeout(() => { if (this.readyState === FakeWS.CLOSED) return if (mode === 'fail') return this.drop() this.readyState = FakeWS.OPEN this.dispatchEvent(new Event('open')) if (mode === 'accept') this.push({ event: 'websocket.connection_established', data: JSON.stringify({ socket_id: `${this.index}.1` }) }) }, 5) } send(raw) { const msg = JSON.parse(raw) this.sent.push(msg) const base = String(msg.event).replace(/\[.*\]$/, '') if (base === 'websocket.ping' || FakeWS.answer[base] === false) return if (FakeWS.answer[base] && FakeWS.answer[base] !== true && FakeWS.answer[base] !== this.index) return setTimeout(() => { if (this.readyState !== FakeWS.OPEN) return if (msg.event === 'websocket.subscribe') { this.push({ event: 'websocket.subscribe:response', data: { channel: msg.data.channel }, channel: msg.data.channel }) } else { this.push({ event: msg.event + ':response', data: { ok: true, via: this.index }, channel: msg.channel }) } }, 2) } close() { if (this.readyState === FakeWS.CLOSED) return this.readyState = FakeWS.CLOSED setTimeout(() => this.dispatchEvent(new Event('close')), 3) } /** Network failure: error + close. */ drop() { this.readyState = FakeWS.CLOSED this.dispatchEvent(new Event('error')) this.dispatchEvent(new Event('close')) } push(obj) { this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(obj) })) } } const realWebSocket = globalThis.WebSocket const clients = [] beforeEach(() => { FakeWS.plan = [] FakeWS.sockets = [] FakeWS.answer = {} globalThis.WebSocket = FakeWS }) afterEach(() => { for (const c of clients.splice(0)) c.destroy() globalThis.WebSocket = realWebSocket }) const client = (extra = {}) => { const c = createWsClient({ url: 'ws://test/app/key', reconnectDelay: 20, reconnectThrottle: 40, heartbeatInterval: 60_000, showConnectionNotifications: false, ...extra, }) clients.push(c) return c } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) /** Resolves to the value, or the string 'PENDING' if still unsettled after ms. */ const within = (p, ms = 800) => Promise.race([p, sleep(ms).then(() => 'PENDING')]) // ── Tests ─────────────────────────────────────────────────────────────────── test('baseline: a send resolves on a healthy connection', async () => { const ws = client() ws.setAppReady() const r = await within(ws.send('dash.index')) assert.deepEqual(r, { ok: true, via: 0 }) }) test('a send waiting on the app\'s first connect survives that attempt failing (setAppReady first)', async () => { // The app bootstraps the socket; pages send while it is still connecting. FakeWS.plan = ['fail', 'accept'] const ws = client() ws.setAppReady() ws.connect().catch(() => {}) const r = await within(ws.send('dash.index')) assert.deepEqual(r, { ok: true, via: 1 }, 'the send must not hang on the dropped connection promise') assert.equal(ws.is_setup.value, true) }) test('a send after a failed connect that landed on the reconnect throttle still completes (connect before setAppReady)', async () => { FakeWS.plan = ['fail', 'accept'] const ws = client() await ws.connect().catch(() => {}) // fails fast; no auto-reconnect yet (app not ready) ws.setAppReady() const r = await within(ws.send('dash.index')) assert.deepEqual(r, { ok: true, via: 1 }, 'a throttled connect must be retried, not dropped') }) test('unchanged: a send that itself opens the connection rejects when that attempt fails', async () => { FakeWS.plan = ['fail', 'accept'] const ws = client() ws.setAppReady() const r = await within(ws.send('dash.index').then(() => 'RESOLVED', (e) => e?.message)) assert.equal(r, 'WebSocket error') }) test('a forced reconnect is not undone by the old socket\'s late close event', async () => { const ws = client() ws.setAppReady() await within(ws.send('dash.index')) ws.last_reconnect_try = 0 await ws.connect(true) await sleep(30) // the old socket's close event fires meanwhile assert.equal(ws.socket, FakeWS.sockets[1], 'the live socket must survive the stale close') const r = await within(ws.send('dash.index')) assert.deepEqual(r, { ok: true, via: 1 }) }) test('destroy() stays destroyed: the closing socket does not trigger an auto-reconnect', async () => { const ws = client() ws.setAppReady() await within(ws.send('dash.index')) ws.destroy() await sleep(80) assert.equal(FakeWS.sockets.length, 1, 'no new socket after destroy()') }) test('a subscribe lost with its socket is retried on the next connection', async () => { FakeWS.plan = ['accept', 'accept'] FakeWS.answer = { 'websocket.subscribe': 1 } // only socket #1 answers subscribes const ws = client() ws.setAppReady() const p = ws.send('dash.index') await sleep(30) // subscribe sent on socket #0, unanswered FakeWS.sockets[0].drop() const r = await within(p) assert.deepEqual(r, { ok: true, via: 1 }) }) test('rejectOnDisconnect: a send in flight on a dropped socket rejects with connection_lost', async () => { FakeWS.answer = { 'slow.call': false } const ws = client({ rejectOnDisconnect: true }) ws.setAppReady() await within(ws.send('dash.index')) const p = ws.send('slow.call') await sleep(20) FakeWS.sockets[0].drop() const r = await within(p.then(() => 'RESOLVED', (e) => e)) assert.equal(r?.code, 'connection_lost') assert.ok(typeof r?.message === 'string' && r.message.length > 0) }) test('default (no rejectOnDisconnect): an in-flight send stays pending — behaviour unchanged', async () => { FakeWS.answer = { 'slow.call': false } const ws = client() ws.setAppReady() await within(ws.send('dash.index')) const p = ws.send('slow.call') await sleep(20) FakeWS.sockets[0].drop() assert.equal(await within(p, 150), 'PENDING') }) test('sends queued while disconnected are delivered after the reconnect', async () => { const ws = client() ws.setAppReady() await within(ws.send('dash.index')) FakeWS.sockets[0].drop() const r = await within(ws.send('after.drop')) assert.deepEqual(r, { ok: true, via: 1 }) }) test('destroy() cancels an auto-reconnect that a drop had already scheduled', async () => { const ws = client() ws.setAppReady() await within(ws.send('dash.index')) FakeWS.sockets[0].drop() // schedules the auto-reconnect (reconnectDelay 20 ms) ws.destroy() await sleep(80) assert.equal(FakeWS.sockets.length, 1, 'no reconnect after destroy()') })