/** * TASK-0092 R4/R5 — the RealtimeProvider connectivity + token-refresh state * machine (the spec's named unit test: "the connectivity state machine * (online→offline→online transitions, the token-expiry reconnect)"). * * A driven fake `WSAdapter` (the provider never touches a real socket) + * an injectable timer queue (the reconnect backoff is real logic, not wall * time — the test flushes it manually) + a no-op offline factory (IndexedDB * is a browser concern; the state machine under test is connectivity, not * persistence). */ import { describe, expect, it } from "vitest"; import * as Y from "yjs"; import { createRealtimeProvider } from "./provider.js"; import { WS_CLOSE, type ConnectionState, type RealtimeProviderConfig, type WSAdapter, type YjsDocBinding, } from "./types.js"; // --- fakes ----------------------------------------------------------------- /** A Yjs doc binding over a real (empty) Y.Doc — the provider's update * source/sink; the test only needs the lifecycle callbacks, not ProseMirror. */ function makeBinding(): YjsDocBinding { const doc = new Y.Doc(); let localCb: ((u: Uint8Array) => void) | null = null; return { doc, applyUpdate: (u) => { if (u.length > 0) Y.applyUpdate(doc, u, "net"); }, onLocalUpdate: (cb) => { localCb = cb; return () => { localCb = null; }; }, // Force the empty-doc path (a full `sync.request {}`, not a state-vector // diff) — the connectivity tests don't care about seeding. hasLocalState: () => false, stateVector: () => Y.encodeStateVector(doc), encodeStateAsUpdate: () => Y.encodeStateAsUpdate(doc) }; } /** A controllable WebSocket: the test drives open/message/close. */ class FakeSocket implements WSAdapter { readyState = 0; // CONNECTING onopen: (() => void) | null = null; onmessage: ((e: { data: string | ArrayBuffer | Uint8Array }) => void) | null = null; onclose: ((e: { code: number; reason: string }) => void) | null = null; onerror: ((e: unknown) => void) | null = null; readonly sent: Array = []; readonly url: string; constructor(url: string) { this.url = url; } send(data: string | ArrayBuffer | Uint8Array): void { this.sent.push(data); } close(): void { this.readyState = 3; } // test drivers _open(): void { this.readyState = 1; this.onopen?.(); } _text(obj: unknown): void { this.onmessage?.({ data: JSON.stringify(obj) }); } _close(code: number, reason = ""): void { this.readyState = 3; this.onclose?.({ code, reason }); } } interface RigOverrides { isOnline?: () => boolean; refreshAccessToken?: () => Promise; onClosed?: (code: number, hint: string) => void; } interface Rig { provider: ReturnType; sockets: FakeSocket[]; states: ConnectionState[]; /** Flush all pending reconnect timers (the injected queue). */ tick: () => void; refreshCalls: () => number; setOnline: (b: boolean) => void; } /** Build the provider with a MANUAL timer queue (the test flushes it — the * reconnect backoff is exercised without wall-clock sleeps). */ function makeRig(overrides: RigOverrides = {}): Rig { const sockets: FakeSocket[] = []; const states: ConnectionState[] = []; let timerQueue: Array<() => void> = []; let refreshCalls = 0; let online = overrides.isOnline ? overrides.isOnline() : true; const config: RealtimeProviderConfig = { pageId: "pg_1", workspaceId: "ws_1", wsUrl: "ws://realtime.test", // After a refresh the handshake uses the new token (the 4001-retry test // asserts the fresh socket URL carries it). getToken: () => (refreshCalls > 0 ? "tok-refreshed" : "tok"), refreshAccessToken: overrides.refreshAccessToken ?? (async () => { refreshCalls += 1; return "tok-refreshed"; }), binding: makeBinding(), createSocket: (url) => { const s = new FakeSocket(url); sockets.push(s); return s; }, isOnline: () => online, // No IndexedDB in the state-machine test (the browser default would try). offlineFactory: { create: async (docName: string) => ({ docName, flush: async () => {}, dispose: async () => {} }) }, // A 1000-min app-level ping — the test never flushes it. pingIntervalMs: 60_000_000, onStateChange: (s) => { states.push(s); }, // exactOptionalPropertyTypes: only include onClosed when provided. ...(overrides.onClosed !== undefined ? { onClosed: overrides.onClosed } : {}), // The injectable timer queue (the reconnect backoff, flushed by tick). setTimeout: ((fn: () => void) => { timerQueue.push(fn); return 0 as unknown as ReturnType; }) as unknown as typeof globalThis.setTimeout, clearTimeout: (() => {}) as unknown as typeof globalThis.clearTimeout }; const provider = createRealtimeProvider(config); return { provider, sockets, states, refreshCalls: () => refreshCalls, tick: () => { const q = timerQueue; timerQueue = []; for (const fn of q) fn(); }, setOnline: (b: boolean) => { online = b; provider.setOnline(b); } }; } /** Connect → open → hello → sync.done (a live `connected` session). */ async function reachConnected(rig: Rig): Promise { await rig.provider.connect(); const s = rig.sockets[0]!; s._open(); s._text({ type: "hello", connId: "c1", canEdit: true, roomPeers: [] }); s._text({ type: "sync.state", chunk: "", done: true }); expect(rig.provider.connectionState).toBe("connected"); return s; } /** Flush microtasks (the 4001-refresh path is async: `await * refreshAccessToken()` runs before `scheduleReconnect`). The test's own * global setTimeout is the REAL one (only the provider's config timer is * injected), so a 0 ms real timer is the flush point. */ function flushMicrotasks(): Promise { return new Promise((r) => globalThis.setTimeout(r, 0)); } describe("RealtimeProvider — connectivity + token-refresh state machine (R4/R5)", () => { it("online → connecting → syncing → connected (the happy path, empty doc → full sync)", async () => { const rig = makeRig(); await rig.provider.connect(); const s = rig.sockets[0]!; s._open(); s._text({ type: "hello", connId: "c1", canEdit: true, roomPeers: [] }); // hello → the empty doc sends a FULL sync.request (no state vector to // diff) before any sync.state arrives. const req = s.sent.find((x) => typeof x === "string" && JSON.parse(x).type === "sync.request") as string; expect(JSON.parse(req)).toEqual({ type: "sync.request" }); s._text({ type: "sync.state", chunk: "", done: true }); expect(rig.states).toEqual(["connecting", "syncing", "connected"]); expect(rig.provider.connectionState).toBe("connected"); await rig.provider.dispose(); }); it("offline → 'offline' (no socket); setOnline(true) → reconnects (the online/offline transition)", async () => { const rig = makeRig({ isOnline: () => false }); await rig.provider.connect(); // No socket was opened while offline. expect(rig.sockets).toHaveLength(0); expect(rig.states).toEqual(["offline"]); // Back online → the provider reconnects (a new socket). connect() is // async (the offline factory resolves before openSocket) → flush. rig.setOnline(true); await flushMicrotasks(); expect(rig.sockets).toHaveLength(1); expect(rig.states).toContain("connecting"); await rig.provider.dispose(); }); it("a 4001 (expired token) mid-session → refreshAccessToken + reconnect ONCE, then connected", async () => { const rig = makeRig(); await reachConnected(rig); // The token expires → the server closes 4001. rig.sockets[0]!._close(WS_CLOSE.Unauthorized); await flushMicrotasks(); // the async refresh + scheduleReconnect(0) expect(rig.states).toContain("reconnecting"); // The refresh was requested exactly once… expect(rig.refreshCalls()).toBe(1); // …and the reconnect timer (scheduleReconnect(0) after a successful // refresh) fires a fresh socket. rig.tick(); expect(rig.sockets).toHaveLength(2); // The fresh socket uses the REFRESHED token (the handshake URL carries it). expect(rig.sockets[1]!.url).toContain(encodeURIComponent("tok-refreshed")); rig.sockets[1]!._open(); rig.sockets[1]!._text({ type: "hello", connId: "c2", canEdit: true, roomPeers: [] }); rig.sockets[1]!._text({ type: "sync.state", chunk: "", done: true }); expect(rig.provider.connectionState).toBe("connected"); await rig.provider.dispose(); }); it("a SECOND 4001 after a refresh → terminal (not a token problem): closed, no reconnect loop", async () => { let closedCode: number | null = null; const rig = makeRig({ onClosed: (code) => (closedCode = code) }); await reachConnected(rig); // First 4001 → refresh (hello reset reconnectAttempts/refreshedOnce). rig.sockets[0]!._close(WS_CLOSE.Unauthorized); await flushMicrotasks(); // the async refresh resolves rig.tick(); // scheduleReconnect(0) → new socket const s2 = rig.sockets[1]!; // The refreshed token is STILL rejected (a non-token problem) → 4001 again. s2._open(); s2._close(WS_CLOSE.Unauthorized); // Terminal: closed, no third socket, no further refresh. expect(rig.provider.connectionState).toBe("closed"); expect(rig.sockets).toHaveLength(2); expect(rig.refreshCalls()).toBe(1); expect(closedCode).toBe(WS_CLOSE.Unauthorized); // Flushing timers does nothing (closedByUs stops the loop). rig.tick(); expect(rig.sockets).toHaveLength(2); }); it("a transient close (4000 protocol/heartbeat) → reconnect with backoff, recovers", async () => { const rig = makeRig(); await reachConnected(rig); // A protocol/heartbeat close (4000) — a transient network blip. rig.sockets[0]!._close(WS_CLOSE.ProtocolOrHeartbeat); expect(rig.states).toContain("reconnecting"); // The backoff timer fires a new socket. rig.tick(); expect(rig.sockets).toHaveLength(2); rig.sockets[1]!._open(); rig.sockets[1]!._text({ type: "hello", connId: "c1b", canEdit: true, roomPeers: [] }); rig.sockets[1]!._text({ type: "sync.state", chunk: "", done: true }); expect(rig.provider.connectionState).toBe("connected"); await rig.provider.dispose(); }); it("4002 (forbidden, lost access) → terminal closed (the app navigates away)", async () => { let closedCode: number | null = null; const rig = makeRig({ onClosed: (code) => (closedCode = code) }); await rig.provider.connect(); rig.sockets[0]!._open(); rig.sockets[0]!._close(WS_CLOSE.Forbidden); expect(rig.provider.connectionState).toBe("closed"); expect(closedCode).toBe(WS_CLOSE.Forbidden); rig.tick(); expect(rig.sockets).toHaveLength(1); // no reconnect }); });