import { describe, expect, it, vi } from "vitest"; import { AutosaveController, BlockConflictError } from "./autosave.js"; import type { BlocksTransport, BlockDto, SaveState } from "./types.js"; function block( id: string, position: string, content: Record = { text: id }, extra?: { parentId?: string | null; properties?: Record; version?: number }, ): BlockDto { return { id, type: "paragraph", content, ...(extra?.properties !== undefined ? { properties: extra.properties } : {}), parentId: extra?.parentId ?? null, position, version: extra?.version ?? 1, }; } interface FakeTimer { set: (fn: () => void, ms: number) => number; clear: (id: number) => void; } function fakeTimers(): { timers: FakeTimer; advance: (ms: number) => void; now: () => number } { let clock = 0; let nextId = 1; const pending = new Map void }>(); const timers: FakeTimer = { set(fn: () => void, ms: number) { const id = nextId++; pending.set(id, { at: clock + ms, fn }); return id; }, clear(id: number) { pending.delete(id); }, }; const advance = (ms: number): void => { const target = clock + ms; // run timers in expiry order const sorted = [...pending.entries()].sort((a, b) => a[1].at - b[1].at); for (const [id, entry] of sorted) { if (entry.at <= target) { pending.delete(id); entry.fn(); } } clock = target; }; return { timers, advance, now: () => clock }; } function makeTransport() { const sent: unknown[][] = []; const fetches: string[] = []; let onConflict: ((id: string, version: number) => void) | null = null; let fetchImpl: ((id: string) => BlockDto) | null = null; const setOnConflict = (fn: (id: string, version: number) => void): void => { onConflict = fn; }; const setFetchImpl = (fn: (id: string) => BlockDto): void => { fetchImpl = fn; }; // Declared as `BlocksTransport` (not inferred): the default stub throws, // which would otherwise infer `Promise` and reject the per-test // reassignment below. const transport: BlocksTransport = { async sendBatch(operations: readonly unknown[]) { sent.push([...operations]); if (onConflict !== null) { const up = operations.find( (o) => (o as { op?: string }).op === "update", ) as { id?: string } | undefined; if (up?.id !== undefined) onConflict(up.id, 99); } throw new BlockConflictError("never", 0); }, async fetchBlock(id: string) { fetches.push(id); if (fetchImpl === null) { throw new Error("no fetch impl"); } return fetchImpl(id); }, }; return { sent, fetches, setOnConflict, setFetchImpl, transport }; } describe("AutosaveController", () => { it("debounces 800 ms and emits one batch with the correct ops", async () => { const { timers, advance } = fakeTimers(); const initial = [block("a", "1"), block("b", "2", { text: "B" })]; const states: SaveState[] = []; const t = makeTransport(); t.transport.sendBatch = async (operations: readonly unknown[]) => { t.sent.push([...operations]); }; const controller = new AutosaveController(initial, { pageId: "p1", transport: t.transport, onStateChange: (s) => { states.push(s); }, setTimeout: (fn: () => void, ms?: number) => { const id = timers.set(fn, ms ?? 0); return id as unknown as ReturnType; }, clearTimeout: (handle) => { timers.clear(handle as unknown as number); }, }); controller.setState([block("a", "1"), block("b", "2", { text: "B2" })]); expect(controller.saveState).toBe("dirty"); expect(t.sent).toHaveLength(0); // not yet advance(799); expect(t.sent).toHaveLength(0); // still debouncing advance(1); // let the (fake-timer-triggered) async flush settle await new Promise((r) => setTimeout(r, 0)); expect(t.sent).toHaveLength(1); expect(t.sent[0]).toEqual([{ op: "update", id: "b", patch: { content: { text: "B2" } } }]); expect(controller.saveState).toBe("saved"); expect(states).toEqual(["dirty", "saving", "saved"]); }); it("no debounce re-arm when state unchanged", async () => { const { timers, advance } = fakeTimers(); const initial = [block("a", "1")]; const t = makeTransport(); t.transport.sendBatch = async (ops: readonly unknown[]) => { t.sent.push([...ops]); }; const controller = new AutosaveController(initial, { pageId: "p1", transport: t.transport, setTimeout: (fn: () => void, ms?: number) => { const id = timers.set(fn, ms ?? 0); return id as unknown as ReturnType; }, clearTimeout: (handle) => { timers.clear(handle as unknown as number); }, }); controller.setState(initial); // identical → idle, no timer expect(controller.saveState).toBe("idle"); advance(10_000); expect(t.sent).toHaveLength(0); }); it("resolves a 409 by rebasing (our content, server placement) and retrying once", async () => { const t = makeTransport(); let conflictCount = 0; t.transport.sendBatch = async (operations: readonly unknown[]) => { t.sent.push([...operations]); conflictCount += 1; if (conflictCount === 1) { throw new BlockConflictError("b", 99); } // second send succeeds }; t.setFetchImpl((id) => block(id, "SERVER_POS", { text: "remote" }, { version: 99 })); const states: SaveState[] = []; const controller = new AutosaveController([block("a", "1"), block("b", "2", { text: "B" })], { pageId: "p1", transport: t.transport, onStateChange: (s) => states.push(s), }); controller.setState([block("a", "1"), block("b", "2", { text: "B-mine" })]); await controller.flush(); expect(t.sent).toHaveLength(2); // original + retry // retry carried the rebased block (content = ours, position = server) expect(controller.saveState).toBe("saved"); // rebased current now has server position + our content expect(states).toContain("saving"); expect(states).toContain("saved"); }); it("surfaces onConflict after a second 409 (never silently overwrites)", async () => { const t = makeTransport(); t.transport.sendBatch = async (operations: readonly unknown[]) => { t.sent.push([...operations]); throw new BlockConflictError("b", 99); }; t.setFetchImpl((id) => block(id, "SP", { text: "remote" }, { version: 99 })); const conflicts: string[] = []; const controller = new AutosaveController([block("b", "2", { text: "B" })], { pageId: "p1", transport: t.transport, onConflict: (info) => { conflicts.push(info.id); }, }); controller.setState([block("b", "2", { text: "B2" })]); await controller.flush(); expect(conflicts).toEqual(["b"]); expect(controller.saveState).toBe("conflict"); expect(t.sent).toHaveLength(2); // original + one retry }); it("chunks a large flush into 200-op batches", async () => { const t = makeTransport(); let n = 0; t.transport.sendBatch = async (ops: readonly unknown[]) => { t.sent.push([...ops]); n += ops.length; }; const initial: BlockDto[] = []; const next: BlockDto[] = []; for (let i = 0; i < 450; i += 1) { next.push(block(`n${i}`, String(i), { text: `x${i}` })); } const controller = new AutosaveController(initial, { pageId: "p1", transport: t.transport, maxBatchOps: 200, }); controller.setState(next); await controller.flush(); expect(t.sent.map((c) => c.length)).toEqual([200, 200, 50]); expect(n).toBe(450); }); });