File size: 5,259 Bytes
45d6656 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | /**
* 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();
});
});
|