Skip to content

Commit 18ccfed

Browse files
committed
test(workspace): prove collaboration review cases
1 parent 4e34268 commit 18ccfed

3 files changed

Lines changed: 198 additions & 93 deletions

File tree

‎services/collaboration/src/crash.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
1010
import { afterEach, describe, expect, it, vi } from "vitest";
1111
import * as Y from "yjs";
1212
import { z } from "zod";
13-
import { documentText, insertParagraph } from "./test/document";
13+
import { appendText, documentText, insertParagraph } from "./test/document";
1414

1515
const childMessage = z.discriminatedUnion("kind", [
1616
z.object({ kind: z.literal("ready"), port: z.number().int().positive() }),
@@ -128,6 +128,13 @@ describe("collaboration crash recovery", () => {
128128
);
129129
await vi.waitFor(() => expect(secondAcks).toHaveLength(1));
130130
expect(JSON.parse(secondAcks[0]).sequence).toBe(persisted.sequence);
131+
132+
appendText(document, " After acknowledgement");
133+
await vi.waitFor(() => expect(secondAcks).toHaveLength(2));
134+
expect(JSON.parse(secondAcks[1])).toMatchObject({
135+
kind: "durable-ack",
136+
sequence: persisted.sequence + 1,
137+
});
131138
await kill(second.child);
132139
reconnected.destroy();
133140

@@ -147,6 +154,7 @@ describe("collaboration crash recovery", () => {
147154
await vi.waitFor(() => {
148155
expect(fresh.isSynced).toBe(true);
149156
expect(documentText(freshDocument)).toContain("Survives restart");
157+
expect(documentText(freshDocument)).toContain("After acknowledgement");
150158
});
151159
},
152160
15_000,

‎services/collaboration/src/server.test.ts‎

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@ class BlockingStore implements DurableUpdateStore {
5858
class MemoryStore implements DurableUpdateStore {
5959
readonly updates: DurableUpdate[] = [];
6060

61-
async load() {
62-
return this.updates;
61+
async load(documentId: string, epoch: number) {
62+
return this.updates.filter(
63+
(update) =>
64+
update.documentId === documentId && update.epoch === epoch,
65+
);
6366
}
6467

6568
async find(documentId: string, epoch: number, mutationId: string) {
@@ -80,7 +83,17 @@ class MemoryStore implements DurableUpdateStore {
8083
);
8184
if (existing) return existing;
8285

83-
const durable = { ...update, sequence: this.updates.length + 1 };
86+
const sequence = Math.max(
87+
0,
88+
...this.updates
89+
.filter(
90+
(existingUpdate) =>
91+
existingUpdate.documentId === update.documentId &&
92+
existingUpdate.epoch === update.epoch,
93+
)
94+
.map(({ sequence }) => sequence),
95+
);
96+
const durable = { ...update, sequence: sequence + 1 };
8497
this.updates.push(durable);
8598
return durable;
8699
}
@@ -185,7 +198,7 @@ describe("collaboration durability", () => {
185198
expect(store.updates[1].syncType).toBe(2);
186199
});
187200

188-
it("returns the original sequence when the same update is delivered twice", async () => {
201+
it("deduplicates identical update bytes within each document and epoch scope", async () => {
189202
const store = new MemoryStore();
190203
const server = createCollaborationServer({
191204
authenticate: async ({ documentName, token }) => ({
@@ -198,35 +211,61 @@ describe("collaboration durability", () => {
198211
await server.listen();
199212
cleanup.push(() => server.destroy());
200213

201-
const document = new Y.Doc();
202-
const acknowledgements: string[] = [];
203-
let updateMessage: Uint8Array | undefined;
204-
const provider = new HocuspocusProvider({
205-
document,
206-
name: "speech-3@1",
207-
onOutgoingMessage: ({ message }) => {
208-
if (message.description === "A document update") {
209-
updateMessage = message.toUint8Array();
210-
}
211-
},
212-
onStateless: ({ payload }) => acknowledgements.push(payload),
213-
token: "author",
214-
url: `ws://127.0.0.1:${server.address.port}`,
215-
});
216-
cleanup.push(() => provider.destroy());
217-
218-
await vi.waitFor(() => expect(provider.isSynced).toBe(true));
219-
insertParagraph(document, "One append");
220-
await vi.waitFor(() => expect(acknowledgements).toHaveLength(1));
221-
if (!updateMessage) throw new Error("Expected an outbound update message");
222-
223-
provider.configuration.websocketProvider.send(updateMessage);
224-
225-
await vi.waitFor(() => expect(acknowledgements).toHaveLength(2));
226-
expect(store.updates).toHaveLength(1);
227-
expect(JSON.parse(acknowledgements[1]).sequence).toBe(
228-
JSON.parse(acknowledgements[0]).sequence,
229-
);
214+
for (const [scopeIndex, name] of [
215+
"speech-a@1",
216+
"speech-b@1",
217+
"speech-a@2",
218+
].entries()) {
219+
const document = new Y.Doc();
220+
const acknowledgements: string[] = [];
221+
let updateMessage: Uint8Array | undefined;
222+
const provider = new HocuspocusProvider({
223+
document,
224+
name,
225+
onOutgoingMessage: ({ message }) => {
226+
if (message.description === "A document update") {
227+
updateMessage = message.toUint8Array();
228+
}
229+
},
230+
onStateless: ({ payload }) => acknowledgements.push(payload),
231+
token: "author",
232+
url: `ws://127.0.0.1:${server.address.port}`,
233+
});
234+
cleanup.push(() => provider.destroy(), () => document.destroy());
235+
236+
await vi.waitFor(() => expect(provider.isSynced).toBe(true));
237+
document.clientID = 1_234_567;
238+
insertParagraph(document, "Identical update");
239+
await vi.waitFor(() => expect(updateMessage).toBeDefined());
240+
await vi.waitFor(() =>
241+
expect(store.updates).toHaveLength(scopeIndex + 1),
242+
);
243+
await vi.waitFor(() => expect(acknowledgements).toHaveLength(1));
244+
if (!updateMessage) {
245+
throw new Error("Expected an outbound update message");
246+
}
247+
expect(store.updates[scopeIndex].bytes).toEqual(store.updates[0].bytes);
248+
249+
provider.configuration.websocketProvider.send(updateMessage);
250+
await vi.waitFor(() => expect(acknowledgements).toHaveLength(2));
251+
252+
const original = JSON.parse(acknowledgements[0]);
253+
const retry = JSON.parse(acknowledgements[1]);
254+
expect(original.sequence).toBe(1);
255+
expect(retry.sequence).toBe(original.sequence);
256+
}
257+
258+
expect(
259+
store.updates.map(({ documentId, epoch, sequence }) => ({
260+
documentId,
261+
epoch,
262+
sequence,
263+
})),
264+
).toEqual([
265+
{ documentId: "speech-a", epoch: 1, sequence: 1 },
266+
{ documentId: "speech-b", epoch: 1, sequence: 1 },
267+
{ documentId: "speech-a", epoch: 2, sequence: 1 },
268+
]);
230269
});
231270

232271
it("does not apply, broadcast, or acknowledge an update when append fails", async () => {
Lines changed: 117 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,157 @@
1-
import { afterEach, describe, expect, it } from "vitest";
1+
import { HocuspocusProvider } from "@hocuspocus/provider";
2+
import { afterEach, describe, expect, it, vi } from "vitest";
23
import * as Y from "yjs";
34
import { createWorkspaceEditor } from "@/lib/workspace/editor-schema";
5+
import {
6+
createCollaborationServer,
7+
type DurableUpdate,
8+
type DurableUpdateStore,
9+
} from "../../../services/collaboration/src/server";
410

5-
function connectDocuments(left: Y.Doc, right: Y.Doc) {
6-
const leftOrigin = Symbol("left");
7-
const rightOrigin = Symbol("right");
8-
const leftUpdates: Uint8Array[] = [];
9-
const rightUpdates: Uint8Array[] = [];
11+
class MemoryStore implements DurableUpdateStore {
12+
readonly updates: DurableUpdate[] = [];
1013

11-
left.on("update", (update, origin) => {
12-
if (origin !== rightOrigin) leftUpdates.push(update);
13-
});
14-
right.on("update", (update, origin) => {
15-
if (origin !== leftOrigin) rightUpdates.push(update);
16-
});
14+
async load(documentId: string, epoch: number) {
15+
return this.updates.filter(
16+
(update) =>
17+
update.documentId === documentId && update.epoch === epoch,
18+
);
19+
}
20+
21+
async find(documentId: string, epoch: number, mutationId: string) {
22+
return this.updates.find(
23+
(update) =>
24+
update.documentId === documentId &&
25+
update.epoch === epoch &&
26+
update.mutationId === mutationId,
27+
);
28+
}
29+
30+
async append(update: Omit<DurableUpdate, "sequence">) {
31+
const existing = await this.find(
32+
update.documentId,
33+
update.epoch,
34+
update.mutationId,
35+
);
36+
if (existing) return existing;
1737

18-
return () => {
19-
if (leftUpdates.length) {
20-
Y.applyUpdate(right, Y.mergeUpdates(leftUpdates.splice(0)), leftOrigin);
21-
}
22-
if (rightUpdates.length) {
23-
Y.applyUpdate(left, Y.mergeUpdates(rightUpdates.splice(0)), rightOrigin);
24-
}
25-
};
38+
const durable = { ...update, sequence: this.updates.length + 1 };
39+
this.updates.push(durable);
40+
return durable;
41+
}
2642
}
2743

2844
describe("workspace editor collaboration", () => {
29-
const cleanup: Array<() => void> = [];
45+
const cleanup: Array<() => void | Promise<void>> = [];
3046

31-
afterEach(() => {
32-
cleanup.splice(0).forEach((dispose) => dispose());
47+
afterEach(async () => {
48+
await Promise.all(cleanup.splice(0).map((dispose) => dispose()));
3349
});
3450

35-
it("converges structured paragraph, list, and link edits after reconnect", () => {
51+
async function createSession() {
52+
const server = (() => {
53+
const deno = Object.getOwnPropertyDescriptor(globalThis, "Deno");
54+
try {
55+
Reflect.deleteProperty(globalThis, "Deno");
56+
return createCollaborationServer({
57+
authenticate: async ({ documentName, token }) => ({
58+
authorId: token,
59+
documentId: documentName.split("@")[0],
60+
epoch: Number(documentName.split("@")[1]),
61+
}),
62+
store: new MemoryStore(),
63+
});
64+
} finally {
65+
if (deno) Object.defineProperty(globalThis, "Deno", deno);
66+
}
67+
})();
68+
await server.listen();
69+
3670
const leftDocument = new Y.Doc();
3771
const rightDocument = new Y.Doc();
3872
const left = createWorkspaceEditor(leftDocument);
3973
const right = createWorkspaceEditor(rightDocument);
40-
const exchange = connectDocuments(leftDocument, rightDocument);
74+
const url = `ws://127.0.0.1:${server.address.port}`;
75+
const leftProvider = new HocuspocusProvider({
76+
document: leftDocument,
77+
name: "structured-editor@1",
78+
token: "left",
79+
url,
80+
});
81+
const rightProvider = new HocuspocusProvider({
82+
document: rightDocument,
83+
name: "structured-editor@1",
84+
token: "right",
85+
url,
86+
});
4187
cleanup.push(
88+
() => leftProvider.destroy(),
89+
() => rightProvider.destroy(),
4290
() => left.destroy(),
4391
() => right.destroy(),
4492
() => leftDocument.destroy(),
4593
() => rightDocument.destroy(),
94+
() => server.destroy(),
4695
);
4796

48-
left.commands.setContent("<p>Opening claim</p>");
49-
exchange();
97+
await vi.waitFor(() => {
98+
expect(leftProvider.isSynced).toBe(true);
99+
expect(rightProvider.isSynced).toBe(true);
100+
});
50101

51-
left.commands.insertContentAt(1, "For ");
52-
right.commands.insertContentAt(1, "Against ");
53-
exchange();
102+
return { left, leftProvider, right, rightProvider };
103+
}
54104

55-
left.commands.selectAll();
56-
expect(left.commands.toggleBulletList()).toBe(true);
57-
exchange();
105+
it("converges structured paragraph, list, and link edits after reconnect", async () => {
106+
const { left, leftProvider, right, rightProvider } = await createSession();
58107

59-
right.commands.selectAll();
60-
expect(right.commands.setLink({ href: "https://example.com/evidence" })).toBe(
61-
true,
62-
);
63-
exchange();
108+
left.commands.setContent("<p>Opening claim</p><p>Evidence</p>");
109+
await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
64110

65-
expect(left.getJSON()).toEqual(right.getJSON());
66-
expect(left.getJSON().content?.[0]?.type).toBe("bulletList");
67-
expect(JSON.stringify(left.getJSON())).toContain(
68-
"https://example.com/evidence",
111+
rightProvider.disconnect();
112+
await vi.waitFor(() => expect(rightProvider.isSynced).toBe(false));
113+
114+
left.commands.insertContentAt(
115+
left.state.doc.content.size,
116+
"<p>Concurrent rebuttal</p>",
69117
);
118+
right.commands.insertContentAt(1, "Offline ");
119+
right.commands.selectAll();
120+
expect(right.commands.toggleBulletList()).toBe(true);
121+
right.commands.selectAll();
122+
expect(
123+
right.commands.setLink({ href: "https://example.com/evidence" }),
124+
).toBe(true);
125+
126+
rightProvider.connect();
127+
await vi.waitFor(() => {
128+
expect(leftProvider.isSynced).toBe(true);
129+
expect(rightProvider.isSynced).toBe(true);
130+
expect(right.getJSON()).toEqual(left.getJSON());
131+
});
132+
133+
const json = JSON.stringify(left.getJSON());
134+
expect(json).toContain('"type":"paragraph"');
135+
expect(json).toContain('"type":"bulletList"');
136+
expect(json).toContain('"type":"link"');
137+
expect(json).toContain("Offline Opening claim");
138+
expect(json).toContain("Concurrent rebuttal");
70139
});
71140

72-
it("undoes only the current editor's last change", () => {
73-
const leftDocument = new Y.Doc();
74-
const rightDocument = new Y.Doc();
75-
const left = createWorkspaceEditor(leftDocument);
76-
const right = createWorkspaceEditor(rightDocument);
77-
const exchange = connectDocuments(leftDocument, rightDocument);
78-
cleanup.push(
79-
() => left.destroy(),
80-
() => right.destroy(),
81-
() => leftDocument.destroy(),
82-
() => rightDocument.destroy(),
83-
);
141+
it("undoes only the current editor's last change through the provider", async () => {
142+
const { left, right } = await createSession();
84143

85144
left.commands.setContent("<p>Base</p>");
86-
exchange();
145+
await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
87146
left.commands.insertContentAt(5, " Alice");
88-
exchange();
147+
await vi.waitFor(() => expect(right.getText()).toContain("Alice"));
89148
right.commands.insertContentAt(11, " Bob");
90-
exchange();
149+
await vi.waitFor(() => expect(left.getText()).toContain("Bob"));
91150

92151
expect(left.commands.undo()).toBe(true);
93-
exchange();
152+
await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON()));
94153

95154
expect(left.getText()).toContain("Bob");
96155
expect(left.getText()).not.toContain("Alice");
97-
expect(left.getJSON()).toEqual(right.getJSON());
98156
});
99157
});

0 commit comments

Comments
 (0)