Files
upstream-hocuspocus/tests/server/flushDelay.ts
upstream-mirror e0c588ca1c
Some checks failed
build / lint (22) (push) Failing after 17s
build / test (22) (push) Failing after 8s
build / lint (23) (push) Failing after 2s
build / build (22) (push) Has been skipped
build / build (23) (push) Has been skipped
build / test (23) (push) Failing after 4s
docsearch / docsearch (push) Failing after 1s
snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
2026-09-15 17:21:35 +05:00

269 lines
7.5 KiB
TypeScript

import test from 'ava'
import { Document } from '@hocuspocus/server'
import * as decoding from 'lib0/decoding'
import * as Y from 'yjs'
import { newHocuspocus, newHocuspocusProvider } from '../utils/index.ts'
import { retryableAssertion } from '../utils/retryableAssertion.ts'
type FakeConnection = {
messageAddress: string
sent: Uint8Array[]
send: (message: Uint8Array) => void
}
const addConnection = (document: Document): FakeConnection => {
const connection: FakeConnection = {
messageAddress: document.name,
sent: [],
send(message: Uint8Array) {
this.sent.push(message)
},
}
document.connections.set(connection as any, { clients: new Set() })
return connection
}
/** Resolves after the flush scheduled with `flushDelay: 0` has run. */
const tick = () => new Promise(resolve => setImmediate(resolve))
const readUpdate = (message: Uint8Array) => {
const decoder = decoding.createDecoder(message)
decoding.readVarString(decoder) // message address
decoding.readVarUint(decoder) // MessageType.Sync
decoding.readVarUint(decoder) // messageYjsUpdate
return decoding.readVarUint8Array(decoder)
}
const readAwarenessStates = (message: Uint8Array) => {
const decoder = decoding.createDecoder(message)
decoding.readVarString(decoder) // message address
decoding.readVarUint(decoder) // MessageType.Awareness
const payload = decoding.createDecoder(decoding.readVarUint8Array(decoder))
const states = new Map<number, any>()
const count = decoding.readVarUint(payload)
for (let i = 0; i < count; i += 1) {
const clientId = decoding.readVarUint(payload)
decoding.readVarUint(payload) // clock
states.set(clientId, JSON.parse(decoding.readVarString(payload)))
}
return states
}
test('sends one message per change when flushDelay is false', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: false })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
t.is(connection.sent.length, 3)
})
test('batches by default', async t => {
const document = new Document('hocuspocus-test')
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
t.is(document.flushDelay, 0)
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
})
test('a server batches by default', async t => {
const server = await newHocuspocus(t)
t.is(server.configuration.flushDelay, 0)
})
test('merges updates from the same tick into a single message', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
// Nothing goes out synchronously; the flush is scheduled for the end of the turn.
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
})
test('the merged message carries every buffered change', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
await tick()
const target = new Y.Doc()
Y.applyUpdate(target, readUpdate(connection.sent[0]))
const map = target.getMap('test')
t.is(map.get('a'), 1)
t.is(map.get('b'), 2)
t.is(map.get('c'), 3)
})
test('collapses awareness changes to the latest state of each client', async t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.awareness.setLocalState({ cursor: 1 })
document.awareness.setLocalState({ cursor: 2 })
document.awareness.setLocalState({ cursor: 3 })
t.is(connection.sent.length, 0)
await tick()
t.is(connection.sent.length, 1)
const states = readAwarenessStates(connection.sent[0])
t.is(states.size, 1)
t.is(states.get(document.clientID).cursor, 3)
})
test('flushes early once flushMaxBytes is buffered', t => {
const document = new Document('hocuspocus-test', undefined, {
flushDelay: 0,
flushMaxBytes: 1,
})
const connection = addConnection(document)
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
// Every update trips the limit, so each is sent without waiting for the flush.
t.is(connection.sent.length, 2)
})
test('calls onUpdate synchronously for every change even when batching', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
let updates = 0
document.callbacks.onUpdate = () => {
updates += 1
}
document.getMap('test').set('a', 1)
document.getMap('test').set('b', 2)
document.getMap('test').set('c', 3)
// The broadcast is deferred, the hook that drives onChange / onStoreDocument
// and the Redis publish is not.
t.is(updates, 3)
t.is(connection.sent.length, 0)
})
test('flush is a no-op when nothing is pending', t => {
const document = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const connection = addConnection(document)
document.flush()
document.flush()
t.is(connection.sent.length, 0)
})
test('destroy flushes what is still buffered instead of dropping it', async t => {
const batched = new Document('hocuspocus-test', undefined, { flushDelay: 0 })
const batchedConnection = addConnection(batched)
batched.getMap('test').set('a', 1)
t.is(batchedConnection.sent.length, 0)
batched.destroy()
// Everything is out by the time destroy returns, and nothing is scheduled
// afterwards.
const afterDestroy = batchedConnection.sent.length
await tick()
t.is(batchedConnection.sent.length, afterDestroy)
// The same document without batching emits exactly as many messages: the
// buffered update plus the awareness teardown that destroy triggers.
const unbatched = new Document('hocuspocus-test', undefined, { flushDelay: false })
const unbatchedConnection = addConnection(unbatched)
unbatched.getMap('test').set('a', 1)
unbatched.destroy()
t.is(batchedConnection.sent.length, unbatchedConnection.sent.length)
})
test('a batching server still converges on connected clients', async t => {
const server = await newHocuspocus(t, { flushDelay: 0 })
const writer = newHocuspocusProvider(t, server, { name: 'flush-converge' })
const reader = newHocuspocusProvider(t, server, { name: 'flush-converge' })
await new Promise(resolve => reader.on('synced', () => resolve('done')))
writer.document.getMap('test').set('a', 1)
writer.document.getMap('test').set('b', 2)
writer.document.getMap('test').set('c', 3)
await retryableAssertion(t, tt => {
const map = reader.document.getMap('test')
tt.is(map.get('a'), 1)
tt.is(map.get('b'), 2)
tt.is(map.get('c'), 3)
})
})
test('onChange still fires per change when batching is enabled', async t => {
let changes = 0
const server = await newHocuspocus(t, {
flushDelay: 0,
async onChange() {
changes += 1
},
})
const provider = newHocuspocusProvider(t, server, {
name: 'flush-onchange',
awareness: null,
})
await new Promise(resolve => provider.on('synced', () => resolve('done')))
provider.document.getMap('test').set('a', 1)
provider.document.getMap('test').set('b', 2)
provider.document.getMap('test').set('c', 3)
await retryableAssertion(t, tt => {
tt.is(changes, 3)
})
})