feat(notes): push, long polling and fallback detection for note sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m16s
Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
after three failures it falls back to polling every 3 s and retries long
polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
leases (renewal), otherwise every 30 s as a safety net. Focusing a block
announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
a stream that stays silent for 5 s (buffering proxy) switches SSE off for
the tab. App-level PING/PONG detects WebSockets that are accepted but
forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
polling, interval fallback, switching), realtime fallback tests with fake
WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
(0.2-0.3 s on all live channels, ~3 s with plain polling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa397c18cd
commit
8b7a53daab
18 files changed
+783
-37
No files matched your search
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Minimal stand-ins: tests decide when connections open and which messages arrive.
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: ((e: { wasClean: boolean; code: number }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEventSource {
|
||||
static instances: FakeEventSource[] = [];
|
||||
closed = false;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
open() {
|
||||
this.onopen?.();
|
||||
}
|
||||
emit(event: string, payload: unknown = {}) {
|
||||
this.onmessage?.({ data: JSON.stringify({ event, payload }) });
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApi() {
|
||||
vi.resetModules(); // fresh module state (transport flags) for every test
|
||||
return import('../api');
|
||||
}
|
||||
|
||||
describe('realtime fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeWebSocket.instances = [];
|
||||
FakeEventSource.instances = [];
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('mindsift_jwt_token', 'test-token');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('a WebSocket that stays silent is replaced by SSE', async () => {
|
||||
const api = await loadApi();
|
||||
const states: string[] = [];
|
||||
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
|
||||
api.connectWebSocket();
|
||||
FakeWebSocket.instances[0].open();
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ event: 'PING' }));
|
||||
await vi.advanceTimersByTimeAsync(40_000); // no message at all, not even PONG
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
FakeEventSource.instances[0].open();
|
||||
FakeEventSource.instances[0].emit('SSE_CONNECTED', { sseId: 'sse_1' });
|
||||
expect(api.getRealtimeTransport()).toBe('sse');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a WebSocket answering PONG stays in use', async () => {
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.open();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
ws.onmessage?.({ data: JSON.stringify({ event: 'PONG' }) });
|
||||
}
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('SSE only counts as connected once the first event arrives', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
es.emit('SSE_CONNECTED', { sseId: 'sse_1' });
|
||||
expect(api.isRealtimeConnected()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(api.isRealtimeConnected()).toBe(true);
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
expect(es.closed).toBe(true);
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
expect(sessionStorage.getItem('mindsift_sse_buffered')).toBe('true');
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user