snapshot of https://github.com/ueberdosis/hocuspocus.git @ cf3cfa355b4d3bb61c6b6c80071f71783e32470e (2026-09-15)
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
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
This commit is contained in:
18
tests/providerwebsocket/configuration.ts
Normal file
18
tests/providerwebsocket/configuration.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import test from 'ava'
|
||||
import { newHocuspocus, newHocuspocusProviderWebsocket } from '../utils/index.ts'
|
||||
|
||||
test('has default configuration (maxDelay = 30000)', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
const client = newHocuspocusProviderWebsocket(t, server)
|
||||
|
||||
t.is(client.configuration.maxDelay, 30000)
|
||||
})
|
||||
|
||||
test('overwrites the default configuration', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
const client = newHocuspocusProviderWebsocket(t, server, {
|
||||
maxDelay: 10000,
|
||||
})
|
||||
|
||||
t.is(client.configuration.maxDelay, 10000)
|
||||
})
|
||||
151
tests/providerwebsocket/maxAttempts.ts
Normal file
151
tests/providerwebsocket/maxAttempts.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
HocuspocusProviderWebsocket,
|
||||
type onMaxAttemptsFailedParameters,
|
||||
} from "@hocuspocus/provider";
|
||||
import test from "ava";
|
||||
import { sleep } from "../utils/index.ts";
|
||||
|
||||
const exhaustedRetryOptions = {
|
||||
url: "ws://127.0.0.1:1",
|
||||
maxAttempts: 1,
|
||||
delay: 1,
|
||||
minDelay: 1,
|
||||
initialDelay: 0,
|
||||
jitter: false,
|
||||
};
|
||||
|
||||
test("does not produce an unhandled rejection when maxAttempts are exhausted", async (t) => {
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
t.teardown(() => {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
});
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket(exhaustedRetryOptions);
|
||||
t.teardown(() => ws.destroy());
|
||||
|
||||
await sleep(300);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
t.is(unhandled.length, 0);
|
||||
});
|
||||
|
||||
test("onMaxAttemptsFailed is executed when maxAttempts are exhausted", async (t) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("onMaxAttemptsFailed was not called"));
|
||||
}, 2000);
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
...exhaustedRetryOptions,
|
||||
onMaxAttemptsFailed({ error }) {
|
||||
clearTimeout(timeout);
|
||||
t.truthy(error);
|
||||
ws.destroy();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
t.teardown(() => ws.destroy());
|
||||
});
|
||||
});
|
||||
|
||||
test("on('maxAttemptsFailed') is executed when maxAttempts are exhausted", async (t) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("maxAttemptsFailed was not emitted"));
|
||||
}, 2000);
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
...exhaustedRetryOptions,
|
||||
autoConnect: false,
|
||||
});
|
||||
t.teardown(() => ws.destroy());
|
||||
|
||||
ws.on("maxAttemptsFailed", ({ error }: onMaxAttemptsFailedParameters) => {
|
||||
clearTimeout(timeout);
|
||||
t.truthy(error);
|
||||
ws.destroy();
|
||||
resolve("done");
|
||||
});
|
||||
|
||||
ws.connect();
|
||||
});
|
||||
});
|
||||
|
||||
// A server that accepts the upgrade and immediately closes the socket never
|
||||
// makes the client emit an `error` event – only a `close` one.
|
||||
class ClosingWebSocket {
|
||||
binaryType = "arraybuffer";
|
||||
identifier = 0;
|
||||
listeners: Record<string, ((payload: any) => void)[]> = {};
|
||||
|
||||
constructor(_url: string) {
|
||||
setTimeout(() => {
|
||||
for (const handler of this.listeners.close ?? []) {
|
||||
handler({ code: 1006, reason: "" });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
addEventListener(name: string, handler: (payload: any) => void) {
|
||||
this.listeners[name] = [...(this.listeners[name] ?? []), handler];
|
||||
}
|
||||
|
||||
removeEventListener(name: string, handler: (payload: any) => void) {
|
||||
this.listeners[name] = (this.listeners[name] ?? []).filter(
|
||||
(listener) => listener !== handler,
|
||||
);
|
||||
}
|
||||
|
||||
close() {}
|
||||
|
||||
send() {}
|
||||
}
|
||||
|
||||
test("emits maxAttemptsFailed when the socket closes without an error", async (t) => {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error("maxAttemptsFailed was not emitted"));
|
||||
}, 2000);
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
...exhaustedRetryOptions,
|
||||
WebSocketPolyfill: ClosingWebSocket,
|
||||
onMaxAttemptsFailed({ error }) {
|
||||
clearTimeout(timeout);
|
||||
t.truthy(error);
|
||||
ws.destroy();
|
||||
resolve("done");
|
||||
},
|
||||
});
|
||||
t.teardown(() => ws.destroy());
|
||||
});
|
||||
});
|
||||
|
||||
test("does not emit maxAttemptsFailed when the connection attempt is aborted", async (t) => {
|
||||
let failed = false;
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: "ws://127.0.0.1:1",
|
||||
maxAttempts: 5,
|
||||
delay: 50,
|
||||
minDelay: 50,
|
||||
initialDelay: 0,
|
||||
jitter: false,
|
||||
onMaxAttemptsFailed() {
|
||||
failed = true;
|
||||
},
|
||||
});
|
||||
t.teardown(() => ws.destroy());
|
||||
|
||||
// Give the first attempt a chance to fail, then stop retrying.
|
||||
await sleep(60);
|
||||
ws.disconnect();
|
||||
await sleep(400);
|
||||
|
||||
t.false(failed);
|
||||
});
|
||||
160
tests/providerwebsocket/messageQueueDeduplication.ts
Normal file
160
tests/providerwebsocket/messageQueueDeduplication.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import test from 'ava'
|
||||
import * as encoding from 'lib0/encoding'
|
||||
import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'
|
||||
import { newHocuspocus } from '../utils/index.ts'
|
||||
|
||||
/**
|
||||
* Helper to create a raw message Uint8Array with a given documentName and messageType,
|
||||
* matching the wire format: VarString(documentName) + VarUint(messageType) + optional payload.
|
||||
*/
|
||||
function createRawMessage(documentName: string, messageType: number, payload?: Uint8Array): Uint8Array {
|
||||
const encoder = encoding.createEncoder()
|
||||
encoding.writeVarString(encoder, documentName)
|
||||
encoding.writeVarUint(encoder, messageType)
|
||||
if (payload) {
|
||||
encoding.writeVarUint8Array(encoder, payload)
|
||||
}
|
||||
return encoding.toUint8Array(encoder)
|
||||
}
|
||||
|
||||
// MessageType enum values (matching provider/src/types.ts)
|
||||
const MessageType = {
|
||||
Sync: 0,
|
||||
Awareness: 1,
|
||||
Auth: 2,
|
||||
QueryAwareness: 3,
|
||||
Stateless: 5,
|
||||
CLOSE: 7,
|
||||
}
|
||||
|
||||
test('deduplicates awareness messages for the same document in the queue', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
// Queue multiple awareness messages for the same document
|
||||
const msg1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([1]))
|
||||
const msg2 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([2]))
|
||||
const msg3 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([3]))
|
||||
|
||||
ws.send(msg1)
|
||||
ws.send(msg2)
|
||||
ws.send(msg3)
|
||||
|
||||
// Access the private messageQueue via cast
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 1, 'should only have the latest awareness message')
|
||||
t.deepEqual(queue[0], msg3, 'should keep the last queued awareness message')
|
||||
})
|
||||
|
||||
test('deduplicates QueryAwareness messages for the same document in the queue', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
const msg1 = createRawMessage('doc1', MessageType.QueryAwareness)
|
||||
const msg2 = createRawMessage('doc1', MessageType.QueryAwareness)
|
||||
|
||||
ws.send(msg1)
|
||||
ws.send(msg2)
|
||||
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 1, 'should only have the latest QueryAwareness message')
|
||||
t.deepEqual(queue[0], msg2)
|
||||
})
|
||||
|
||||
test('does not deduplicate awareness messages for different documents', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
const msg1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([1]))
|
||||
const msg2 = createRawMessage('doc2', MessageType.Awareness, new Uint8Array([2]))
|
||||
|
||||
ws.send(msg1)
|
||||
ws.send(msg2)
|
||||
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 2, 'should keep awareness messages for different documents')
|
||||
})
|
||||
|
||||
test('does not deduplicate Sync messages', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
const msg1 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([1]))
|
||||
const msg2 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([2]))
|
||||
const msg3 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([3]))
|
||||
|
||||
ws.send(msg1)
|
||||
ws.send(msg2)
|
||||
ws.send(msg3)
|
||||
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 3, 'should keep all Sync messages')
|
||||
})
|
||||
|
||||
test('does not deduplicate Stateless messages', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
const msg1 = createRawMessage('doc1', MessageType.Stateless, new Uint8Array([1]))
|
||||
const msg2 = createRawMessage('doc1', MessageType.Stateless, new Uint8Array([2]))
|
||||
|
||||
ws.send(msg1)
|
||||
ws.send(msg2)
|
||||
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 2, 'should keep all Stateless messages')
|
||||
})
|
||||
|
||||
test('deduplicates awareness but preserves sync messages in mixed queue', async t => {
|
||||
const server = await newHocuspocus(t)
|
||||
|
||||
const ws = new HocuspocusProviderWebsocket({
|
||||
url: server.server!.webSocketURL,
|
||||
autoConnect: false,
|
||||
})
|
||||
t.teardown(() => ws.destroy())
|
||||
|
||||
const sync1 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([1]))
|
||||
const awareness1 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([10]))
|
||||
const sync2 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([2]))
|
||||
const awareness2 = createRawMessage('doc1', MessageType.Awareness, new Uint8Array([20]))
|
||||
const sync3 = createRawMessage('doc1', MessageType.Sync, new Uint8Array([3]))
|
||||
|
||||
ws.send(sync1)
|
||||
ws.send(awareness1)
|
||||
ws.send(sync2)
|
||||
ws.send(awareness2)
|
||||
ws.send(sync3)
|
||||
|
||||
const queue = (ws as any).messageQueue as Uint8Array[]
|
||||
t.is(queue.length, 4, 'should have 3 sync + 1 awareness')
|
||||
t.deepEqual(queue[0], sync1)
|
||||
t.deepEqual(queue[1], sync2)
|
||||
t.deepEqual(queue[2], awareness2, 'should keep the latest awareness message')
|
||||
t.deepEqual(queue[3], sync3)
|
||||
})
|
||||
Reference in New Issue
Block a user