265 lines
9.9 KiB
JavaScript
265 lines
9.9 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 = {}
|
|
/** event name → progress payload streamed before the response. */
|
|
static progress = {}
|
|
|
|
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 {
|
|
if (FakeWS.progress[base]) this.push({ event: msg.event + ':progress', data: FakeWS.progress[base], channel: msg.channel })
|
|
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 = {}
|
|
FakeWS.progress = {}
|
|
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()')
|
|
})
|
|
|
|
test('sendTimeout: an unanswered send rejects with code timeout; answered sends are unaffected', async () => {
|
|
FakeWS.answer = { 'slow.call': false }
|
|
const ws = client({ sendTimeout: 50 })
|
|
ws.setAppReady()
|
|
assert.deepEqual(await within(ws.send('dash.index')), { ok: true, via: 0 })
|
|
const r = await within(ws.send('slow.call').then(() => 'RESOLVED', (e) => e))
|
|
assert.equal(r?.code, 'timeout')
|
|
assert.ok(typeof r?.message === 'string' && r.message.length > 0)
|
|
assert.deepEqual(await within(ws.send('dash.index')), { ok: true, via: 0 })
|
|
})
|
|
|
|
test('sendTimeout: a per-call timeout works without a client default, and timeout 0 opts out', async () => {
|
|
FakeWS.answer = { 'slow.call': false }
|
|
const plain = client()
|
|
plain.setAppReady()
|
|
const r = await within(plain.send('slow.call', {}, null, { timeout: 30 }).then(() => 'RESOLVED', (e) => e))
|
|
assert.equal(r?.code, 'timeout')
|
|
|
|
const bounded = client({ sendTimeout: 30 })
|
|
bounded.setAppReady()
|
|
assert.equal(await within(bounded.send('slow.call', {}, null, { timeout: 0 }), 150), 'PENDING')
|
|
})
|
|
|
|
test('sendTimeout: a send that timed out before it was written never goes out after the reconnect', async () => {
|
|
const ws = client({ sendTimeout: 10 })
|
|
ws.setAppReady()
|
|
await within(ws.send('dash.index'))
|
|
FakeWS.sockets[0].drop()
|
|
const r = await within(ws.send('late.call').then(() => 'RESOLVED', (e) => e))
|
|
assert.equal(r?.code, 'timeout')
|
|
await sleep(150)
|
|
assert.ok(FakeWS.sockets.length >= 2, 'the client reconnected')
|
|
const written = FakeWS.sockets.flatMap((s) => s.sent).some((m) => String(m.event).startsWith('late.call'))
|
|
assert.equal(written, false, 'the timed-out send was not written late')
|
|
})
|
|
|
|
test('progress: the 4th argument still takes a callback, or { progress }', async () => {
|
|
FakeWS.progress = { 'stream.call': { step: 1 } }
|
|
const ws = client()
|
|
ws.setAppReady()
|
|
const seen = []
|
|
await within(ws.send('stream.call', {}, null, (d) => seen.push(['fn', d])))
|
|
await within(ws.send('stream.call', {}, null, { progress: (d) => seen.push(['opt', d]), timeout: 500 }))
|
|
assert.deepEqual(seen, [['fn', { step: 1 }], ['opt', { step: 1 }]])
|
|
})
|