Skip to content
142 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import * as decoding from "lib0/decoding";
5import * as encoding from "lib0/encoding";
6import * as syncProtocol from "y-protocols/sync";
7import * as Y from "yjs";
8
9import { FolioProvider, pageStateBytes } from "./provider.ts";
10
11// The provider is browser code; the little it needs of the browser, a page address and sockets, is faked here.
12(globalThis as { location?: unknown }).location = { href: "https://g1t.test/acme/-/artifacts/x", protocol: "https:" };
13
14class FakeSocket {
15 static readonly CONNECTING = 0;
16 static readonly OPEN = 1;
17 static readonly CLOSED = 3;
18 static instances: FakeSocket[] = [];
19 readyState = FakeSocket.CONNECTING;
20 binaryType = "blob";
21 sent: (Uint8Array | string)[] = [];
22 onopen: (() => void) | null = null;
23 onmessage: ((event: { data: unknown }) => void) | null = null;
24 onclose: ((event: { code: number }) => void) | null = null;
25 readonly url: string;
26 constructor(url: string) {
27 this.url = url;
28 FakeSocket.instances.push(this);
29 }
30 send(data: Uint8Array | string) {
31 this.sent.push(data);
32 }
33 close(code = 1000) {
34 this.readyState = FakeSocket.CLOSED;
35 this.onclose?.({ code });
36 }
37 /** The server's turn: a frame for the client. */
38 receive(bytes: Uint8Array) {
39 this.onmessage?.({ data: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) });
40 }
41}
42(globalThis as { WebSocket?: unknown }).WebSocket = FakeSocket;
43
44const MESSAGE_SYNC = 0;
45
46/** A room as src/folios/room.ts behaves: its own step 1 on connect, and an answer to every sync message. */
47function room(doc: Y.Doc, socket: FakeSocket) {
48 const step1 = encoding.createEncoder();
49 encoding.writeVarUint(step1, MESSAGE_SYNC);
50 syncProtocol.writeSyncStep1(step1, doc);
51 socket.receive(encoding.toUint8Array(step1));
52 return {
53 /** Reads what the client sent since, answering as the room would. */
54 exchange() {
55 for (const frame of socket.sent.splice(0)) {
56 if (typeof frame === "string") continue;
57 const decoder = decoding.createDecoder(frame);
58 if (decoding.readVarUint(decoder) !== MESSAGE_SYNC) continue;
59 const encoder = encoding.createEncoder();
60 encoding.writeVarUint(encoder, MESSAGE_SYNC);
61 syncProtocol.readSyncMessage(decoder, encoder, doc, "room");
62 if (encoding.length(encoder) > 1) socket.receive(encoding.toUint8Array(encoder));
63 }
64 },
65 };
66}
67
68function frames(socket: FakeSocket): number[] {
69 return socket.sent.filter((f): f is Uint8Array => typeof f !== "string").map((f) => decoding.readVarUint(decoding.createDecoder(f)));
70}
71
72test("a doc seeded from the page's state opens at once, and what is typed before the room answers reaches it, once", (t) => {
73 FakeSocket.instances = [];
74 // The room's document, as the page carried its saved state.
75 const server = new Y.Doc();
76 server.getText("t").insert(0, "hello");
77 const carried = Y.encodeStateAsUpdate(server);
78 // Someone else edited after the save: the room is ahead of the page.
79 server.getText("t").insert(0, "Oh, ");
80
81 const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f1", { state: carried });
82 t.after(() => provider.destroy());
83 assert.equal(provider.seeded, true);
84 assert.equal(provider.doc.getText("t").toString(), "hello", "the editor can open on the page's document before any socket");
85 assert.equal(provider.status, "connecting");
86 const socket = FakeSocket.instances[0]!;
87 assert.equal(socket.url, "wss://g1t.test/acme/-/artifacts/live?folio=f1");
88
89 // Typed before the socket opened: kept in the document, nothing sent yet.
90 provider.doc.getText("t").insert(5, " world");
91 assert.deepEqual(frames(socket), [], "nothing goes down a socket that is not open");
92
93 socket.readyState = FakeSocket.OPEN;
94 socket.onopen?.();
95 assert.deepEqual(frames(socket), [MESSAGE_SYNC, 1, 3], "on open: our state vector, our presence, and a question about who is here");
96 const theRoom = room(server, socket);
97 theRoom.exchange();
98 theRoom.exchange();
99 assert.equal(provider.status, "synced");
100 assert.equal(server.getText("t").toString(), "Oh, hello world", "the room got the early edit, and only once");
101 assert.equal(provider.doc.getText("t").toString(), "Oh, hello world", "the page got what changed since the save");
102
103 // From here, an edit goes straight down the socket.
104 provider.doc.getText("t").insert(0, "> ");
105 assert.deepEqual(frames(socket), [MESSAGE_SYNC]);
106 theRoom.exchange();
107 assert.equal(server.getText("t").toString(), "> Oh, hello world");
108 provider.destroy();
109});
110
111test("without a saved state the document starts empty and is not seeded, and the room's copy fills it", (t) => {
112 FakeSocket.instances = [];
113 const server = new Y.Doc();
114 server.getText("t").insert(0, "from the room");
115 const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f2", { state: null });
116 t.after(() => provider.destroy());
117 assert.equal(provider.seeded, false);
118 const socket = FakeSocket.instances[0]!;
119 socket.readyState = FakeSocket.OPEN;
120 socket.onopen?.();
121 const theRoom = room(server, socket);
122 theRoom.exchange();
123 assert.equal(provider.status, "synced");
124 assert.equal(provider.doc.getText("t").toString(), "from the room");
125 provider.destroy();
126});
127
128test("a state that is not a Yjs update is ignored, and the provider waits for the room as before", (t) => {
129 FakeSocket.instances = [];
130 const provider = new FolioProvider("wss://g1t.test/acme/-/artifacts/live?folio=f3", { state: new Uint8Array([1, 2, 3, 4, 5, 6, 7]) });
131 t.after(() => provider.destroy());
132 assert.equal(provider.seeded, false);
133 provider.destroy();
134});
135
136test("the page's base64 state becomes bytes, and anything else becomes nothing", (t) => {
137 const bytes = new Uint8Array([0, 1, 2, 250, 251, 252]);
138 assert.deepEqual(pageStateBytes(btoa(String.fromCharCode(...bytes))), bytes);
139 assert.equal(pageStateBytes(null), null);
140 assert.equal(pageStateBytes(""), null);
141 assert.equal(pageStateBytes("not base64!"), null);
142});