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

519 lines
13 KiB
TypeScript

import test from "ava";
import * as Y from "yjs";
import { newHocuspocus, newHocuspocusProvider } from "../utils/index.ts";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
test("executes the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
t.pass();
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("executes the onLoadDocument callback from an extension", async (t) => {
await new Promise(async (resolve) => {
class CustomExtension {
async onLoadDocument() {
t.pass();
resolve("done");
}
}
const server = await newHocuspocus(t, {
extensions: [new CustomExtension()],
});
newHocuspocusProvider(t, server);
});
});
test("passes the context and connection to the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const mockContext = {
user: 123,
};
const server = await newHocuspocus(t, {
async onConnect({ connectionConfig }) {
connectionConfig.readOnly = true;
return mockContext;
},
async onLoadDocument({ context, connectionConfig }) {
t.deepEqual(context, mockContext);
t.deepEqual(connectionConfig, {
readOnly: true,
isAuthenticated: true,
});
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
test("sets the provider to readOnly", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ connectionConfig }) {
connectionConfig.readOnly = true;
},
});
newHocuspocusProvider(t, server, {
onSynced() {
for (const connection of server.documents.get("hocuspocus-test")?.connections.keys() ?? []) {
t.is(connection.readOnly, true);
}
resolve("done");
},
});
});
});
test("creates a new document in the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (resolve) => {
setTimeout(() => {
document.getArray("foo").insert(0, ["bar"]);
resolve(document);
}, 200);
});
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
const value = provider.document.getArray("foo").get(0);
t.is(value, "bar");
resolve("done");
},
});
});
});
test("does not apply the document to itself when onLoadDocument returns it", async (t) => {
const origins: unknown[] = [];
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
document.on("afterTransaction", (transaction) => {
origins.push(transaction.origin);
});
// the content this hook "loaded", written with an explicit origin so
// it doubles as a control: without it, a listener that never fires
// would satisfy the assertion below vacuously
document.transact(() => {
document.getArray("foo").insert(0, ["bar"]);
}, "loaded");
return document;
},
});
newHocuspocusProvider(t, server, {
onSynced() {
resolve("done");
},
});
});
t.true(origins.includes("loaded"), "the afterTransaction listener never fired");
// the self-apply calls applyUpdate() without an origin, and the client sync
// carries a ConnectionTransactionOrigin, so a null origin can only be the
// document having been applied to itself
const selfApplies = origins.filter((origin) => origin == null);
t.is(
selfApplies.length,
0,
`expected no null-origin transaction after onLoadDocument returned the same document, got ${selfApplies.length}`,
);
});
test("applies a document returned by the onLoadDocument callback", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
const loaded = new Y.Doc();
loaded.getArray("foo").insert(0, ["bar"]);
return loaded;
},
});
const provider = newHocuspocusProvider(t, server, {
onSynced() {
t.is(provider.document.getArray("foo").get(0), "bar");
resolve("done");
},
});
});
});
test("multiple simultaneous connections do not create multiple documents", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (resolve) => {
setTimeout(() => {
document.getArray("foo").insert(0, ["bar"]);
resolve(document);
}, 200);
});
},
});
const provider = newHocuspocusProvider(t, server);
newHocuspocusProvider(t, server);
provider.on("synced", () => {
t.is(server.documents.size, 1);
resolve("done");
});
});
});
test("multiple simultaneous connections wait for the document to be loaded", async (t) => {
t.plan(6);
await new Promise(async (resolve) => {
let resolveOnLoadDocument: () => void = () => {};
const server = await newHocuspocus(t, {
onLoadDocument({ document }) {
// delay more accurately simulates a database fetch
return new Promise(async (innerResolve) => {
resolveOnLoadDocument = () => {
document.getArray("foo").insert(0, ["bar"]);
innerResolve(document);
};
});
},
});
const provider1 = newHocuspocusProvider(t, server);
const provider2 = newHocuspocusProvider(t, server);
let provider1Synced = false;
let provider2Synced = false;
provider1.on("synced", () => {
provider1Synced = true;
const value = provider1.document.getArray("foo").get(0);
t.is(value, "bar");
});
provider2.on("synced", () => {
provider2Synced = true;
const value = provider2.document.getArray("foo").get(0);
t.is(value, "bar");
});
await sleep(200);
t.false(provider1Synced, "provider1Synced");
t.false(provider2Synced, "provider2Synced");
resolveOnLoadDocument();
await sleep(200);
t.true(provider1Synced, "provider1Synced");
t.true(provider2Synced, "provider2Synced");
resolve("done");
});
});
test("has the server instance", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ instance }) {
t.is(instance, server);
resolve("done");
},
});
newHocuspocusProvider(t, server);
});
});
/**
* When onLoadDocument fails (for whatever reason), the connection attempt will fail.
*/
test("stops when an error is thrown in onLoadDocument", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
throw new Error();
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
t.pass();
resolve("done");
},
});
});
});
test("stops when an error is thrown in onLoadDocument, even when authenticated", async (t) => {
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onAuthenticate() {
return true;
},
async onLoadDocument() {
throw new Error();
},
});
newHocuspocusProvider(t, server, {
token: "super-secret-token",
onAuthenticationFailed() {
t.pass();
resolve("done");
},
onClose() {
t.fail();
},
});
});
});
test("disconnects all clients related to the document when an error is thrown in onLoadDocument", async (t) => {
const resolvesNeeded = 2;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
return new Promise((resolve, fail) => {
setTimeout(() => {
// eslint-disable-next-line prefer-promise-reject-errors
fail("ERROR");
}, 250);
});
},
async onStoreDocument(data) {
t.fail("MUST NOT call onStoreDocument");
},
});
let resolvedNumber = 0;
const resolver = () => {
resolvedNumber += 1;
if (resolvedNumber >= resolvesNeeded) {
t.is(server.documents.size, 0);
t.is(server.getConnectionsCount(), 0);
resolve("done");
}
};
const provider1 = newHocuspocusProvider(t, server, {
onAuthenticationFailed(event) {
provider1.disconnect();
resolver();
},
});
const provider2 = newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
provider2.disconnect();
resolver();
},
});
});
});
test("destroys the document when onLoadDocument throws", async (t) => {
let loadedDocument: Y.Doc | undefined;
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument({ document }) {
loadedDocument = document;
throw new Error("Something went wrong");
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed() {
resolve("done");
},
});
});
t.truthy(loadedDocument, "onLoadDocument never ran");
// Awareness starts a setInterval in its constructor that is only cleared by
// the document's destroy(), so a document that is never destroyed leaves a
// live timer behind for every failed load
t.true(
loadedDocument?.isDestroyed,
"the document created for the failed load was never destroyed",
);
});
test("if a new connection connects while the previous connection still fetches the document, it will just work properly", async (t) => {
t.plan(11);
let callsToOnLoadDocument = 0;
const resolvesNeeded = 10;
await new Promise(async (resolve) => {
let resolvedNumber = 0;
const resolver = () => {
resolvedNumber += 1;
if (resolvedNumber >= resolvesNeeded) {
t.is(callsToOnLoadDocument, 1);
resolve("done");
}
};
const server = await newHocuspocus(t, {
// This test asserts on the document state at each individual inbound
// message, so it pins the unbatched fan-out. With the default
// `flushDelay: 0` a broadcast lands one event loop turn later, which
// reorders it relative to the sync acks and shifts these indices.
flushDelay: false,
onLoadDocument({ document }) {
return new Promise(async (resolve) => {
setTimeout(() => {
callsToOnLoadDocument += 1;
document
.getArray("foo")
.insert(0, [`bar-${callsToOnLoadDocument}`]);
resolve(document);
}, 5000);
});
},
});
let provider1MessagesReceived = 0;
const provider = newHocuspocusProvider(t, server, {
onSynced({ state }) {
// if (!state) return
t.is(server.documents.size, 1);
const value = provider.document.getArray("foo").get(0);
t.is(value, "bar-1");
setTimeout(() => {
provider.document
.getArray("foo")
.insert(0, ["bar-updatedAfterProvider1Synced"]);
}, 100);
resolver();
},
onMessage() {
if (!provider.isSynced) return;
provider1MessagesReceived += 1;
const value = provider.document.getArray("foo").get(0);
if (provider1MessagesReceived === 1) {
// do nothing, this is just the ACK for the sync
} else if (provider1MessagesReceived === 2) {
// do nothing, this is just the ACK for the received update (set "bar-updatedAfterProvider1Synced")
} else if (provider1MessagesReceived === 3) {
t.is(value, "bar-updatedAfterProvider1Synced");
} else {
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
}
resolver();
},
});
let provider2MessagesReceived = 0;
setTimeout(() => {
const provider2 = newHocuspocusProvider(t, server, {
onSynced({ state }) {
// if (!state) return
t.is(server.documents.size, 1);
const value = provider2.document.getArray("foo").get(0);
t.is(value, "bar-1");
resolver();
},
onMessage(data) {
if (!provider2.isSynced) return;
provider2MessagesReceived += 1;
setTimeout(() => {
const value = provider2.document.getArray("foo").get(0);
if (provider2MessagesReceived === 1) {
// initial state is now synced
t.is(value, "bar-1");
} else if (provider2MessagesReceived === 2) {
t.is(value, "bar-updatedAfterProvider1Synced");
setTimeout(() => {
provider.document
.getArray("foo")
.insert(0, ["bar-updatedAfterProvider2ReceivedMessageFrom1"]);
}, 100);
} else {
t.is(value, "bar-updatedAfterProvider2ReceivedMessageFrom1");
}
resolver();
});
},
});
}, 2000);
});
});
test("stops when an error is thrown in onLoadDocument and passed on to the provider", async (t) => {
class CustomError extends Error {
code = 1234;
reason = "mycustomreason";
constructor(documentName: string) {
super(`Failed loading ${documentName}`);
}
}
await new Promise(async (resolve) => {
const server = await newHocuspocus(t, {
async onLoadDocument() {
throw new CustomError("testdoc");
},
});
newHocuspocusProvider(t, server, {
onAuthenticationFailed(e) {
t.is(e.reason, "mycustomreason");
t.pass();
resolve("done");
},
});
});
});