diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts index 18acb14a..57ac5a1b 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.test.ts @@ -88,69 +88,89 @@ it("keeps the last usable cursor while retaining only the latest future update", assert.deepEqual(received, []); }); -it("refreshes unchanged cursors on reconnect and heartbeat, and follows the document through renames", async (context) => { - context.mock.timers.enable({ apis: ["setInterval"] }); - const content = new TextEncoder().encode("hello"); - let path = "note.md"; - const database = createDatabase(await hash(content)); - const sent: { - documentsWithCursors: { - relative_path: RelativePath; - vault_update_id: VaultUpdateId | null; - }[]; - }[] = []; - const websocket = { - onWebSocketStatusChanged: new EventListeners< - (connected: boolean) => unknown - >(), - onRemoteCursorsUpdateReceived: new EventListeners(), - updateLocalCursors: (value: (typeof sent)[number]) => { - sent.push(value); +it( + "refreshes unchanged cursors on reconnect and heartbeat, and follows the document through renames", + { timeout: 5_000 }, + async (context) => { + context.mock.timers.enable({ apis: ["setInterval"] }); + const content = new TextEncoder().encode("hello"); + let path = "note.md"; + const database = createDatabase(await hash(content)); + const sent: { + documentsWithCursors: { + relative_path: RelativePath; + vault_update_id: VaultUpdateId | null; + }[]; + }[] = []; + let published = Promise.withResolvers(); + const websocket = { + onWebSocketStatusChanged: new EventListeners< + (connected: boolean) => unknown + >(), + onRemoteCursorsUpdateReceived: new EventListeners(), + updateLocalCursors: (value: (typeof sent)[number]) => { + sent.push(value); + published.resolve(undefined); + } + } as unknown as WebSocketManager; + const notifier = new EventListeners<(path: RelativePath) => unknown>(); + const read = context.mock.fn(async () => ({ content })); + const tracker = new CursorTracker( + database, + websocket, + { read } as unknown as FileSystemOperations, + notifier + ); + const waitForSend = async (trigger: () => void): Promise => { + published = Promise.withResolvers(); + trigger(); + // Hashing finishes outside the event loop; a fixed number of turns + // cannot guarantee that the cursor snapshot has been published. + await published.promise; + }; + try { + await tracker.sendLocalCursorsToServer({ + "note.md": [{ start: 1, end: 2 }] + }); + assert.equal(sent.length, 1); + await tracker.sendLocalCursorsToServer({ + "note.md": [{ start: 1, end: 2 }] + }); + assert.equal(sent.length, 1); + await waitForSend(() => { + websocket.onWebSocketStatusChanged.trigger(true); + }); + assert.equal(sent.length, 2); + await waitForSend(() => { + context.mock.timers.tick(15_000); + }); + assert.equal(sent.length, 3); + path = "renamed.md"; + database.state.local.document = path; + await notifier.triggerAsync(path); + assert.equal( + sent.at(-1)!.documentsWithCursors[0].relative_path, + path + ); + websocket.onWebSocketStatusChanged.trigger(false); + const count = sent.length; + const readCount = read.mock.callCount(); + context.mock.timers.tick(30_000); + // A surviving heartbeat would start a snapshot read in the send lock's + // next microtask, even if hashing that snapshot has not finished yet. + await Promise.resolve(); + assert.equal(read.mock.callCount(), readCount); + assert.equal(sent.length, count); + await waitForSend(() => { + websocket.onWebSocketStatusChanged.trigger(true); + }); + assert.equal(sent.length, count + 1); + } finally { + tracker.reset(); + context.mock.timers.reset(); } - } as unknown as WebSocketManager; - const notifier = new EventListeners<(path: RelativePath) => unknown>(); - const tracker = new CursorTracker( - database, - websocket, - { read: async () => ({ content }) } as unknown as FileSystemOperations, - notifier - ); - const drain = async () => { - for (let i = 0; i < 20; i++) - await new Promise((resolve) => setImmediate(resolve)); - }; - try { - await tracker.sendLocalCursorsToServer({ - "note.md": [{ start: 1, end: 2 }] - }); - assert.equal(sent.length, 1); - await tracker.sendLocalCursorsToServer({ - "note.md": [{ start: 1, end: 2 }] - }); - assert.equal(sent.length, 1); - websocket.onWebSocketStatusChanged.trigger(true); - await drain(); - assert.equal(sent.length, 2); - context.mock.timers.tick(15_000); - await drain(); - assert.equal(sent.length, 3); - path = "renamed.md"; - database.state.local.document = path; - await notifier.triggerAsync(path); - assert.equal(sent.at(-1)!.documentsWithCursors[0].relative_path, path); - websocket.onWebSocketStatusChanged.trigger(false); - const count = sent.length; - context.mock.timers.tick(30_000); - await drain(); - assert.equal(sent.length, count); - websocket.onWebSocketStatusChanged.trigger(true); - await drain(); - assert.equal(sent.length, count + 1); - } finally { - tracker.reset(); - context.mock.timers.reset(); } -}); +); for (const interruption of ["disconnect", "reset"] as const) { it(`an in-flight cursor read cannot resurrect presence after ${interruption}`, async () => {