Files
upstream-hocuspocus/tests/server/openDirectConnection.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

467 lines
13 KiB
TypeScript

import { TiptapTransformer } from "@hocuspocus/transformer";
import test from "ava";
import * as Y from "yjs";
import { newHocuspocus, newHocuspocusProvider, sleep } from "../utils/index.ts";
test("rejects empty document name via direct connection", async (t) => {
const server = await newHocuspocus(t);
await t.throwsAsync(() => server.openDirectConnection(""), {
message: "Document name must not be empty",
});
t.is(server.getDocumentsCount(), 0);
});
test("rejects whitespace-only document name via direct connection", async (t) => {
const server = await newHocuspocus(t);
await t.throwsAsync(() => server.openDirectConnection(" "), {
message: "Document name must not be empty",
});
t.is(server.getDocumentsCount(), 0);
});
test("direct connection prevents document from being removed from memory", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
await server.openDirectConnection("hocuspocus-test");
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.configuration.websocketProvider.destroy();
provider.destroy();
sleep(server.configuration.debounce + 50).then(() => {
t.is(server.getDocumentsCount(), 1);
resolve("done");
});
},
});
});
});
test("direct connection works even if provider is connected", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
const provider = newHocuspocusProvider(t, server, {
onSynced() {
provider.document.getMap("config").set("a", "valueFromProvider");
},
});
await sleep(150);
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
t.is("valueFromProvider", String(doc.getMap("config").get("a")));
doc.getMap("config").set("b", "valueFromServerDirectConnection");
});
await sleep(100);
t.is(
"valueFromServerDirectConnection",
String(provider.document.getMap("config").get("b")),
);
resolve(1);
t.pass();
});
});
test("direct connection can apply yjsUpdate", async (t) => {
const server = await newHocuspocus(t);
const expectedContent = "<paragraph>Example Paragraph</paragraph>";
let resolveProviderSynced!: () => void;
const providerSynced = new Promise<void>((resolve) => {
resolveProviderSynced = resolve;
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
resolveProviderSynced();
},
});
await providerSynced;
const fragment = provider.document.getXmlFragment("default");
t.is(fragment.toJSON(), "");
let resolveUpdateApplied!: () => void;
const updateApplied = new Promise<void>((resolve) => {
resolveUpdateApplied = resolve;
});
const observer = () => {
if (fragment.toJSON() === expectedContent) {
fragment.unobserveDeep(observer);
resolveUpdateApplied();
}
};
fragment.observeDeep(observer);
const directConnection =
await server.openDirectConnection("hocuspocus-test");
await directConnection.transact((doc) => {
Y.applyUpdate(
doc,
Y.encodeStateAsUpdate(
TiptapTransformer.toYdoc({
type: "doc",
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Example Paragraph",
},
],
},
],
}),
),
);
});
await updateApplied;
t.is(fragment.toJSON(), expectedContent);
await directConnection.disconnect();
});
test("direct connection can transact", async (t) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.is(direct.document?.getArray("test").toJSON()[0], "value");
});
test("direct connection cannot transact once closed", async (t) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.disconnect();
try {
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.fail(
"DirectConnection should throw an error when transacting on closed connection",
);
} catch (err) {
if (err instanceof Error && err.message === "direct connection closed") {
t.pass();
} else {
t.fail("unknown error");
}
}
});
test("if a direct connection closes, the document should be unloaded if there is no other connection left", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t);
const direct = await server.openDirectConnection("hocuspocus-test1");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished", async (t) => {
let onStoreDocumentFinished = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
});
const direct = await server.openDirectConnection("hocuspocus-test2");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
resolve("done");
});
});
test("direct connection transact awaits until onStoreDocument has finished, even if unloadImmediately=false", async (t) => {
let onStoreDocumentFinished = false;
let directConnDisconnecting = false;
let storedAfterDisconnect = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
unloadImmediately: false,
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
if (directConnDisconnecting) {
storedAfterDisconnect = true;
}
},
afterUnloadDocument: async (data) => {
if (!storedAfterDisconnect) {
t.fail("this shouldnt be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
const provider = newHocuspocusProvider(t, server);
provider.document.getMap("aaa").set("bb", "b");
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(100);
directConnDisconnecting = true;
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
t.is(server.getConnectionsCount(), 0);
t.is(storedAfterDisconnect, true);
resolve("done");
});
});
test("does not unload document if an earlierly started onStoreDocument is still running", async (t) => {
let onStoreDocumentStarted = 0;
let onStoreDocumentFinished = 0;
const server = await newHocuspocus(t, {
unloadImmediately: false,
debounce: 100,
onStoreDocument: async () => {
onStoreDocumentStarted++;
if (onStoreDocumentStarted === 1) {
// Simulate a long running onStoreDocument for the first debounced save
await sleep(500);
}
onStoreDocumentFinished++;
},
afterUnloadDocument: async (data) => {},
});
// Trigger a change, which will start a debounced onStoreDocument after 100ms
const provider = newHocuspocusProvider(t, server);
provider.document.getMap("aaa").set("bb", "b");
await new Promise(async (resolve) => {
provider.on("synced", resolve);
if (!provider.unsyncedChanges) resolve("");
});
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
// Wait for the debounced onStoreDocument to start
await sleep(110);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Open direct connection to prevent document from being unloaded
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 2);
// Close the websocket client
provider.disconnect();
provider.configuration.websocketProvider.disconnect();
await sleep(50);
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
direct.disconnect();
await sleep(50);
// Another save must not start before the first one has finished
t.is(onStoreDocumentStarted, 1);
t.is(onStoreDocumentFinished, 0);
// Document must not be unloaded yet, because the first onStoreDocument is still running
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 0);
// Wait enough time to be sure the onStoreDocument has finished and ensure that the document was eventually unloaded
await sleep(500);
// The second onStoreDocument triggered by direct.disconnect must have started and finished now
t.is(onStoreDocumentStarted, 2);
t.is(onStoreDocumentFinished, 2);
// The document must have been unloaded now as well
t.is(server.getDocumentsCount(), 0);
});
test("creating a websocket connection after transact but before debounce interval doesnt create different docs", async (t) => {
let onStoreDocumentFinished = false;
let disconnected = false;
let testDone = false;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
onStoreDocumentFinished = false;
await sleep(200);
onStoreDocumentFinished = true;
},
async afterUnloadDocument(data) {
if (disconnected && !testDone) {
t.fail("must not be called");
}
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
t.is(server.getConnectionsCount(), 1);
t.is(onStoreDocumentFinished, false);
await direct.transact((document) => {
document.transact(() => {
document.getArray("test").insert(0, ["value"]);
}, "testOrigin");
});
await direct.disconnect();
t.is(onStoreDocumentFinished, true);
disconnected = true;
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
t.is(onStoreDocumentFinished, true);
const provider = newHocuspocusProvider(t, server);
await sleep(server.configuration.debounce * 2);
testDone = true;
resolve("done");
});
});
test("direct connection passes context", async (t) => {
return new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onChange(x) {
t.is(x.context.x, 123);
t.pass();
resolve();
},
});
const direct = await server.openDirectConnection("hocuspocus-test", {
x: 123,
});
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
t.is(direct.document?.getArray("test").toJSON()[0], "value");
});
});
test("disconnect({ unloadImmediately: false }) keeps the document warm and coalesces stores", async (t) => {
let storeCount = 0;
const server = await newHocuspocus(t, {
debounce: 100,
maxDebounce: 500,
onStoreDocument: async () => {
storeCount += 1;
},
});
const first = await server.openDirectConnection("hocuspocus-test");
await first.transact((document) => {
document.getArray("test").insert(0, ["a"]);
});
await first.disconnect({ unloadImmediately: false });
// The document stays in memory and nothing is persisted yet.
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 1);
t.is(storeCount, 0);
// A follow-up direct connection reuses the warm document.
const second = await server.openDirectConnection("hocuspocus-test");
t.is(server.getDocumentsCount(), 1);
await second.transact((document) => {
document.getArray("test").insert(1, ["b"]);
});
await second.disconnect({ unloadImmediately: false });
t.is(server.getDocumentsCount(), 1);
t.is(storeCount, 0);
// After the debounce window the store flushes once (coalesced) and the
// document is unloaded.
await sleep(server.configuration.maxDebounce + server.configuration.debounce + 100);
t.is(storeCount, 1);
t.is(server.getDocumentsCount(), 0);
});
test("disconnect() without options still persists and unloads immediately", async (t) => {
let storeCount = 0;
const server = await newHocuspocus(t, {
onStoreDocument: async () => {
storeCount += 1;
},
});
const direct = await server.openDirectConnection("hocuspocus-test");
await direct.transact((document) => {
document.getArray("test").insert(0, ["value"]);
});
await direct.disconnect();
t.is(storeCount, 1);
t.is(server.getConnectionsCount(), 0);
t.is(server.getDocumentsCount(), 0);
});