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
519 lines
13 KiB
TypeScript
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");
|
|
},
|
|
});
|
|
});
|
|
});
|