/** * Refocus re-sync guard tests. * * Exercises createRefocusSync across the four liveness cases it must * distinguish on tab refocus, plus the offline grace release. Uses fake * timers and a fake provider/editor - no network, no browser. */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createRefocusSync, type RefocusProviderLike, type RefocusEditorLike, } from "../src/editor/refocus-sync"; interface Fakes { provider: RefocusProviderLike & { emit: (event: string) => void }; editor: RefocusEditorLike & { setEditable: ReturnType }; socket: { status: string; webSocket: { readyState: number }; lastMessageReceived: number }; } function makeFakes(opts: { synced?: boolean; status?: string; readyState?: number; /** Simulate a healthy socket: forceSync draws a server reply that advances lastMessageReceived. */ replyOnForceSync?: boolean; } = {}): Fakes { const listeners: Record void>> = {}; const socket = { status: opts.status ?? "connected", webSocket: { readyState: opts.readyState ?? 1 }, lastMessageReceived: 0, }; const provider: Fakes["provider"] = { synced: opts.synced ?? true, configuration: { websocketProvider: socket }, connect: vi.fn(() => Promise.resolve()), forceSync: vi.fn(() => { if (opts.replyOnForceSync) socket.lastMessageReceived += 1; }), on: (event, cb) => { (listeners[event] ??= []).push(cb); }, off: (event, cb) => { listeners[event] = (listeners[event] ?? []).filter((f) => f !== cb); }, emit: (event) => { (listeners[event] ?? []).forEach((f) => f()); }, }; const editor: Fakes["editor"] = { isEditable: true, isDestroyed: false, setEditable: vi.fn(function (this: unknown, editable: boolean) { editor.isEditable = editable; }), }; return { provider, editor, socket }; } function lastEditableArg(setEditable: ReturnType): boolean | undefined { const call = setEditable.mock.calls.at(-1); return call ? (call[0] as boolean) : undefined; } describe("createRefocusSync", () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); it("healthy + synced socket: forces a sync but never locks", () => { const { provider, editor } = makeFakes({ synced: true, replyOnForceSync: true }); const { onVisibility } = createRefocusSync({ provider, editor }); onVisibility(); expect(provider.connect).toHaveBeenCalled(); expect(provider.forceSync).toHaveBeenCalled(); expect(editor.setEditable).not.toHaveBeenCalled(); // Probe window passes; reply advanced lastMessageReceived -> still no lock. vi.advanceTimersByTime(2000); expect(editor.setEditable).not.toHaveBeenCalledWith(false); }); it("disconnected socket: locks immediately, unlocks on synced", () => { const { provider, editor } = makeFakes({ synced: false, status: "disconnected" }); const { onVisibility } = createRefocusSync({ provider, editor }); onVisibility(); expect(editor.setEditable).toHaveBeenCalledWith(false); expect(editor.isEditable).toBe(false); provider.emit("synced"); expect(lastEditableArg(editor.setEditable)).toBe(true); expect(editor.isEditable).toBe(true); }); it("closed readyState while status says connected: locks", () => { const { provider, editor } = makeFakes({ synced: true, status: "connected", readyState: 3 }); const { onVisibility } = createRefocusSync({ provider, editor }); onVisibility(); expect(editor.setEditable).toHaveBeenCalledWith(false); }); it("offline: never traps the user, releases after the grace window", () => { const { provider, editor } = makeFakes({ synced: false }); createRefocusSync({ provider, editor }).onVisibility(); expect(editor.isEditable).toBe(false); vi.advanceTimersByTime(4000); expect(lastEditableArg(editor.setEditable)).toBe(true); expect(editor.isEditable).toBe(true); }); it("half-open socket: looks alive but no reply -> locks after probe", () => { const { provider, editor } = makeFakes({ synced: true, status: "connected", readyState: 1, replyOnForceSync: false, }); const { onVisibility } = createRefocusSync({ provider, editor }); onVisibility(); // Looks alive, so no immediate lock. expect(editor.setEditable).not.toHaveBeenCalledWith(false); // No message landed within the probe window -> declare it stale. vi.advanceTimersByTime(2000); expect(editor.setEditable).toHaveBeenCalledWith(false); expect(provider.connect).toHaveBeenCalledTimes(2); // initial + probe reconnect }); it("dispose detaches the synced listener and releases the lock", () => { const { provider, editor } = makeFakes({ synced: false }); const { onVisibility, dispose } = createRefocusSync({ provider, editor }); onVisibility(); expect(editor.isEditable).toBe(false); dispose(); expect(editor.isEditable).toBe(true); // A late synced event must not touch a disposed editor. editor.setEditable.mockClear(); provider.emit("synced"); expect(editor.setEditable).not.toHaveBeenCalled(); }); });