npm-networking/test/ws.test.mjs

265 lines
9.9 KiB
JavaScript
Raw Normal View History

// 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 }]])
})