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:
518
tests/server/onLoadDocument.ts
Normal file
518
tests/server/onLoadDocument.ts
Normal file
@@ -0,0 +1,518 @@
|
||||
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");
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user