Spaces:
Sleeping
Sleeping
| /** | |
| * 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<typeof vi.fn> }; | |
| 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<string, Array<(...a: any[]) => 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<typeof vi.fn>): 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(); | |
| }); | |
| }); | |