215 lines
7.5 KiB
JavaScript
215 lines
7.5 KiB
JavaScript
|
|
// 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()')
|
||
|
|
})
|