Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: CRDT race on snapshot load + 73 provider lifecycle tests (#95)

scott ba0d4667 3af17ced

+1850 -2
+8
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.9.7] — 2026-03-23 11 + 12 + ### Fixed 13 + - **CRDT race on snapshot load**: `ensureSheet(0)` and TipTap editor initialization were writing to the Y.Doc before the async `_loadSnapshot()` completed, creating CRDT conflicts that overwrote loaded server data ~50% of the time. Added `whenReady` promise to EncryptedProvider; both sheets and docs now await it before touching the doc (#205) 14 + 15 + ### Added 16 + - **Comprehensive save/load test suite**: 73 behavioral tests covering the full provider lifecycle — `whenReady`, snapshot loading, CRDT race prevention, save flow, debounced save, emergency save (sendBeacon/IDB), periodic saves, reconnection, version history, destroy lifecycle, save status transitions, multi-sheet Yjs round-trip integrity, doc update propagation, and connection status events (#205) 17 + 10 18 ## [0.9.6] — 2026-03-23 11 19 12 20 ### Fixed
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.9.6", 3 + "version": "0.9.7", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+4
src/docs/main.ts
··· 91 91 const ydoc = new Y.Doc(); 92 92 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 93 93 94 + // Wait for snapshot to load before creating the editor — prevents CRDT conflict 95 + // where TipTap writes default content that conflicts with loaded data 96 + await provider.whenReady; 97 + 94 98 const COLORS = [ 95 99 '#e06c5e', '#d4893b', '#5ea3e0', '#5ec48a', '#9b7ec4', 96 100 '#c45e8a', '#5eb8b0', '#8a7e5e', '#7e8ac4', '#c4a65e',
+12 -1
src/lib/provider.ts
··· 90 90 _lastEncrypted: ArrayBuffer | Uint8Array | null; 91 91 _saveInProgress: boolean; 92 92 93 + /** Resolves after the initial snapshot load completes (success or failure). 94 + * Await this before writing to the Y.Doc to prevent CRDT conflicts with loaded data. */ 95 + whenReady: Promise<void>; 96 + _resolveReady!: () => void; 97 + 93 98 _onDocUpdate: (update: Uint8Array, origin: unknown) => void; 94 99 _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void; 95 100 _onBeforeUnload: (event: BeforeUnloadEvent) => void; ··· 115 120 this._lastDebounceTrigger = 0; // Timestamp of first debounce in current burst 116 121 this._lastEncrypted = null; // Cached encrypted state for synchronous sendBeacon 117 122 this._saveInProgress = false; // Prevents concurrent _saveSnapshot calls 123 + this.whenReady = new Promise<void>(resolve => { this._resolveReady = resolve; }); 118 124 119 125 // Bind handlers 120 126 this._onDocUpdate = this._handleDocUpdate.bind(this); ··· 154 160 155 161 // Load persisted snapshot on first connect only — reconnects already have doc state 156 162 if (!this._hadSnapshot && !this.synced) { 157 - await this._loadSnapshot(); 163 + try { 164 + await this._loadSnapshot(); 165 + } finally { 166 + this._resolveReady(); 167 + } 158 168 } 159 169 160 170 const url = `${this.wsUrl}?room=${encodeURIComponent(this.roomId)}`; ··· 583 593 584 594 async destroy(): Promise<void> { 585 595 this._destroyed = true; 596 + this._resolveReady(); // Ensure whenReady resolves even if destroyed early 586 597 clearTimeout(this._saveDebounce!); 587 598 await this._saveSnapshot(); 588 599 this.disconnect();
+4
src/sheets/main.ts
··· 88 88 const ydoc = new Y.Doc(); 89 89 const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 90 90 91 + // Wait for snapshot to load before touching ydoc — prevents CRDT conflict 92 + // where ensureSheet() creates an empty sheet that overwrites loaded data 93 + await provider.whenReady; 94 + 91 95 // Yjs shared types for spreadsheet 92 96 const ySheets = ydoc.getMap('sheets'); 93 97 let activeSheetIdx = 0;
+1774
tests/provider-lifecycle.test.ts
··· 1 + /** 2 + * Comprehensive behavioral tests for the EncryptedProvider lifecycle. 3 + * 4 + * Tests cover the full save/load/sync pipeline as real runtime behavior, 5 + * not source-code inspection. Uses mocked WebSocket, fetch, IDB, and crypto. 6 + * 7 + * Sections: 8 + * 1. whenReady promise 9 + * 2. Snapshot loading 10 + * 3. CRDT race prevention 11 + * 4. Save flow 12 + * 5. Debounced save 13 + * 6. Emergency save (beforeunload/pagehide) 14 + * 7. Periodic saves 15 + * 8. Reconnection 16 + * 9. Version history 17 + * 10. Destroy lifecycle 18 + * 11. Save status transitions 19 + * 12. Multi-sheet Yjs round-trip integrity 20 + * 13. Doc update propagation 21 + * 14. Connection status events 22 + */ 23 + 24 + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 25 + import * as Y from 'yjs'; 26 + import type { EncryptedProvider as EncryptedProviderType } from '../src/lib/provider.js'; 27 + 28 + // --------------------------------------------------------------------------- 29 + // Mock WebSocket 30 + // --------------------------------------------------------------------------- 31 + interface MockWebSocketInstance { 32 + url: string; 33 + readyState: number; 34 + binaryType: string; 35 + onopen: ((ev: Record<string, unknown>) => void) | null; 36 + onmessage: ((ev: { data: string | ArrayBuffer }) => void) | null; 37 + onclose: ((ev: { code: number }) => void) | null; 38 + onerror: (() => void) | null; 39 + _sent: unknown[]; 40 + send: (data: unknown) => void; 41 + close: () => void; 42 + _simulateOpen: () => void; 43 + _simulateMessage: (data: string | ArrayBuffer) => void; 44 + _simulateClose: () => void; 45 + } 46 + 47 + interface MockWebSocketConstructor { 48 + new (url: string): MockWebSocketInstance; 49 + CONNECTING: number; 50 + OPEN: number; 51 + CLOSING: number; 52 + CLOSED: number; 53 + _instances: MockWebSocketInstance[]; 54 + _reset: () => void; 55 + latest: MockWebSocketInstance | undefined; 56 + } 57 + 58 + const MockWebSocket = function (this: MockWebSocketInstance, url: string) { 59 + this.url = url; 60 + this.readyState = MockWebSocket.CONNECTING; 61 + this.binaryType = 'text'; 62 + this.onopen = null; 63 + this.onmessage = null; 64 + this.onclose = null; 65 + this.onerror = null; 66 + this._sent = []; 67 + MockWebSocket._instances.push(this); 68 + } as unknown as MockWebSocketConstructor; 69 + 70 + MockWebSocket.CONNECTING = 0; 71 + MockWebSocket.OPEN = 1; 72 + MockWebSocket.CLOSING = 2; 73 + MockWebSocket.CLOSED = 3; 74 + MockWebSocket._instances = [] as MockWebSocketInstance[]; 75 + MockWebSocket._reset = function () { 76 + MockWebSocket._instances = []; 77 + }; 78 + Object.defineProperty(MockWebSocket, 'latest', { 79 + get() { 80 + return MockWebSocket._instances[MockWebSocket._instances.length - 1]; 81 + }, 82 + }); 83 + 84 + MockWebSocket.prototype.send = function (this: MockWebSocketInstance, data: unknown) { 85 + this._sent.push(data); 86 + }; 87 + MockWebSocket.prototype.close = function (this: MockWebSocketInstance) { 88 + this.readyState = MockWebSocket.CLOSED; 89 + if (this.onclose) this.onclose({ code: 1000 }); 90 + }; 91 + MockWebSocket.prototype._simulateOpen = function (this: MockWebSocketInstance) { 92 + this.readyState = MockWebSocket.OPEN; 93 + if (this.onopen) this.onopen({}); 94 + }; 95 + MockWebSocket.prototype._simulateMessage = function ( 96 + this: MockWebSocketInstance, 97 + data: string | ArrayBuffer, 98 + ) { 99 + if (this.onmessage) this.onmessage({ data }); 100 + }; 101 + MockWebSocket.prototype._simulateClose = function (this: MockWebSocketInstance) { 102 + this.readyState = MockWebSocket.CLOSED; 103 + if (this.onclose) this.onclose({ code: 1000 }); 104 + }; 105 + 106 + // --------------------------------------------------------------------------- 107 + // Mock fetch 108 + // --------------------------------------------------------------------------- 109 + interface FetchCall { 110 + url: string; 111 + opts: RequestInit | undefined; 112 + } 113 + 114 + interface MockResponse { 115 + ok: boolean; 116 + status?: number; 117 + json?: () => Promise<unknown>; 118 + arrayBuffer?: () => Promise<ArrayBuffer>; 119 + } 120 + 121 + let fetchCalls: FetchCall[] = []; 122 + let fetchResponses: Record<string, MockResponse | (() => Promise<MockResponse>)> = {}; 123 + 124 + function mockFetch(url: string, opts?: RequestInit): Promise<MockResponse> { 125 + fetchCalls.push({ url, opts }); 126 + // Support method-specific keys like "PUT:/api/..." and "POST:/api/..." 127 + const method = (opts?.method || 'GET').toUpperCase(); 128 + const key = `${method}:${url}`; 129 + const resp = fetchResponses[key] || fetchResponses[url]; 130 + if (typeof resp === 'function') return resp(); 131 + if (resp) return Promise.resolve(resp); 132 + // Defaults 133 + if (method === 'PUT' || method === 'POST') { 134 + return Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true }) }); 135 + } 136 + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({ error: 'Not found' }) }); 137 + } 138 + 139 + // --------------------------------------------------------------------------- 140 + // Mock sendBeacon 141 + // --------------------------------------------------------------------------- 142 + let sendBeaconCalls: Array<{ url: string; data: unknown }> = []; 143 + function mockSendBeacon(url: string, data?: unknown): boolean { 144 + sendBeaconCalls.push({ url, data }); 145 + return true; 146 + } 147 + 148 + // --------------------------------------------------------------------------- 149 + // Mock saveLocalBackup / loadLocalBackup 150 + // --------------------------------------------------------------------------- 151 + let idbStore: Record<string, { encrypted: ArrayBuffer | Uint8Array }> = {}; 152 + const saveLocalBackupSpy = vi.fn(async (docId: string, encrypted: ArrayBuffer | Uint8Array) => { 153 + idbStore[docId] = { encrypted }; 154 + }); 155 + const loadLocalBackupSpy = vi.fn(async (docId: string) => { 156 + const entry = idbStore[docId]; 157 + if (!entry) return null; 158 + return { id: `${docId}-0-0`, docId, encrypted: entry.encrypted, hash: 0, timestamp: Date.now() }; 159 + }); 160 + 161 + // --------------------------------------------------------------------------- 162 + // Setup / teardown — fresh mocks per test 163 + // --------------------------------------------------------------------------- 164 + let EncryptedProvider: typeof EncryptedProviderType; 165 + 166 + beforeEach(async () => { 167 + MockWebSocket._reset(); 168 + fetchCalls = []; 169 + fetchResponses = {}; 170 + sendBeaconCalls = []; 171 + idbStore = {}; 172 + saveLocalBackupSpy.mockClear(); 173 + loadLocalBackupSpy.mockClear(); 174 + 175 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket; 176 + globalThis.fetch = mockFetch as unknown as typeof fetch; 177 + (globalThis as Record<string, unknown>).location = { protocol: 'https:', host: 'localhost:3000' }; 178 + (globalThis as Record<string, unknown>).document = { hidden: false, addEventListener: vi.fn() }; 179 + (globalThis as Record<string, unknown>).window = { 180 + addEventListener: vi.fn(), 181 + removeEventListener: vi.fn(), 182 + __importInProgress: false, 183 + }; 184 + Object.defineProperty(globalThis, 'navigator', { 185 + value: { sendBeacon: mockSendBeacon }, 186 + writable: true, 187 + configurable: true, 188 + }); 189 + 190 + vi.doMock('../src/lib/crypto.js', () => ({ 191 + encrypt: vi.fn(async (data: Uint8Array) => data), // passthrough 192 + decrypt: vi.fn(async (data: Uint8Array) => data), // passthrough 193 + })); 194 + 195 + vi.doMock('../src/lib/local-backup.js', () => ({ 196 + saveLocalBackup: saveLocalBackupSpy, 197 + loadLocalBackup: loadLocalBackupSpy, 198 + })); 199 + 200 + const mod = await import('../src/lib/provider.js'); 201 + EncryptedProvider = mod.EncryptedProvider; 202 + }); 203 + 204 + afterEach(() => { 205 + vi.useRealTimers(); 206 + vi.restoreAllMocks(); 207 + vi.resetModules(); 208 + }); 209 + 210 + // --------------------------------------------------------------------------- 211 + // Helpers 212 + // --------------------------------------------------------------------------- 213 + 214 + /** Create a provider and wait for the WebSocket to be created. */ 215 + async function createProvider( 216 + roomId = 'test-room', 217 + snapshotResponse?: MockResponse, 218 + ): Promise<{ doc: Y.Doc; provider: EncryptedProviderType; ws: MockWebSocketInstance }> { 219 + if (snapshotResponse) { 220 + fetchResponses[`GET:/api/documents/${roomId}/snapshot`] = snapshotResponse; 221 + } 222 + const doc = new Y.Doc(); 223 + const cryptoKey = {} as CryptoKey; 224 + const provider = new EncryptedProvider(doc, roomId, cryptoKey, { 225 + wsUrl: 'wss://localhost:3000/ws', 226 + apiUrl: '', 227 + }); 228 + await vi.waitFor(() => { 229 + expect(MockWebSocket.latest).toBeDefined(); 230 + }, { timeout: 1000 }); 231 + return { doc, provider, ws: MockWebSocket.latest! }; 232 + } 233 + 234 + /** Create a valid Yjs state from a map of key→value pairs. */ 235 + function makeYjsState(data: Record<string, string>): Uint8Array { 236 + const doc = new Y.Doc(); 237 + const map = doc.getMap('data'); 238 + for (const [k, v] of Object.entries(data)) map.set(k, v); 239 + return Y.encodeStateAsUpdate(doc); 240 + } 241 + 242 + /** Create a valid Yjs sheets state (like sheets/main.ts would). */ 243 + function makeSheetsState(cells: Record<string, { v: unknown; f?: string; s?: string }>): Uint8Array { 244 + const doc = new Y.Doc(); 245 + const ySheets = doc.getMap('sheets'); 246 + const sheet = new Y.Map(); 247 + sheet.set('name', 'Sheet 1'); 248 + const yCells = new Y.Map(); 249 + for (const [id, data] of Object.entries(cells)) { 250 + const cell = new Y.Map(); 251 + cell.set('v', data.v); 252 + if (data.f) cell.set('f', data.f); 253 + if (data.s) cell.set('s', data.s); 254 + yCells.set(id, cell); 255 + } 256 + sheet.set('cells', yCells); 257 + sheet.set('colWidths', new Y.Map()); 258 + sheet.set('rowCount', 100); 259 + sheet.set('colCount', 26); 260 + ySheets.set('sheet_0', sheet); 261 + return Y.encodeStateAsUpdate(doc); 262 + } 263 + 264 + /** Simulate full sync: open WS + peer-count 0. */ 265 + function syncProvider(ws: MockWebSocketInstance): void { 266 + ws._simulateOpen(); 267 + ws._simulateMessage(JSON.stringify({ type: 'peer-count', count: 0 })); 268 + } 269 + 270 + /** Get PUT fetch calls for snapshot endpoint. */ 271 + function snapshotPutCalls(): FetchCall[] { 272 + return fetchCalls.filter(c => c.opts?.method === 'PUT' && c.url.includes('/snapshot')); 273 + } 274 + 275 + /** Get POST fetch calls for version endpoint. */ 276 + function versionPostCalls(): FetchCall[] { 277 + return fetchCalls.filter(c => c.opts?.method === 'POST' && c.url.includes('/versions')); 278 + } 279 + 280 + // =========================================================================== 281 + // 1. whenReady promise 282 + // =========================================================================== 283 + describe('whenReady promise', () => { 284 + it('resolves after successful snapshot load from server', async () => { 285 + const state = makeYjsState({ key: 'loaded from server' }); 286 + fetchResponses['GET:/api/documents/test-room/snapshot'] = { 287 + ok: true, 288 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 289 + }; 290 + 291 + const doc = new Y.Doc(); 292 + const provider = new EncryptedProvider(doc, 'test-room', {} as CryptoKey, { 293 + wsUrl: 'wss://localhost:3000/ws', 294 + apiUrl: '', 295 + }); 296 + 297 + await provider.whenReady; 298 + // Data should be in the doc 299 + expect(doc.getMap('data').get('key')).toBe('loaded from server'); 300 + await provider.destroy(); 301 + }); 302 + 303 + it('resolves after snapshot 404 (new document)', async () => { 304 + // Default fetchResponse is 404 — new doc 305 + const doc = new Y.Doc(); 306 + const provider = new EncryptedProvider(doc, 'new-doc', {} as CryptoKey, { 307 + wsUrl: 'wss://localhost:3000/ws', 308 + apiUrl: '', 309 + }); 310 + 311 + await provider.whenReady; 312 + // Doc is empty — that's fine 313 + expect(doc.getMap('data').size).toBe(0); 314 + await provider.destroy(); 315 + }); 316 + 317 + it('resolves after server error (non-404) with local backup fallback', async () => { 318 + // Server returns 500 319 + fetchResponses['GET:/api/documents/err-room/snapshot'] = { 320 + ok: false, status: 500, json: () => Promise.resolve({ error: 'Server error' }), 321 + }; 322 + // Local backup has data 323 + const state = makeYjsState({ key: 'from backup' }); 324 + idbStore['err-room'] = { encrypted: state }; 325 + 326 + const doc = new Y.Doc(); 327 + const provider = new EncryptedProvider(doc, 'err-room', {} as CryptoKey, { 328 + wsUrl: 'wss://localhost:3000/ws', 329 + apiUrl: '', 330 + }); 331 + 332 + await provider.whenReady; 333 + expect(doc.getMap('data').get('key')).toBe('from backup'); 334 + expect(provider._snapshotLoadFailed).toBe(true); 335 + await provider.destroy(); 336 + }); 337 + 338 + it('resolves even if provider is destroyed before connect completes', async () => { 339 + // Slow fetch — won't resolve quickly 340 + fetchResponses['GET:/api/documents/destroyed/snapshot'] = () => 341 + new Promise(() => {}); // never resolves 342 + 343 + const doc = new Y.Doc(); 344 + const provider = new EncryptedProvider(doc, 'destroyed', {} as CryptoKey, { 345 + wsUrl: 'wss://localhost:3000/ws', 346 + apiUrl: '', 347 + }); 348 + 349 + // Destroy immediately 350 + await provider.destroy(); 351 + // whenReady should still resolve (not hang forever) 352 + const result = await Promise.race([ 353 + provider.whenReady.then(() => 'resolved'), 354 + new Promise(r => setTimeout(() => r('timeout'), 200)), 355 + ]); 356 + expect(result).toBe('resolved'); 357 + }); 358 + 359 + it('does not re-trigger snapshot load on reconnect', async () => { 360 + const state = makeYjsState({ key: 'original' }); 361 + fetchResponses['GET:/api/documents/test-room/snapshot'] = { 362 + ok: true, 363 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 364 + }; 365 + 366 + const { provider, ws } = await createProvider(); 367 + await provider.whenReady; 368 + 369 + const loadCallsBefore = fetchCalls.filter(c => 370 + c.url.includes('/snapshot') && (!c.opts || !c.opts.method || c.opts.method === 'GET'), 371 + ).length; 372 + 373 + // Simulate disconnect + reconnect 374 + ws._simulateClose(); 375 + await vi.waitFor(() => { 376 + expect(MockWebSocket._instances.length).toBeGreaterThan(1); 377 + }, { timeout: 5000 }); 378 + 379 + const loadCallsAfter = fetchCalls.filter(c => 380 + c.url.includes('/snapshot') && (!c.opts || !c.opts.method || c.opts.method === 'GET'), 381 + ).length; 382 + 383 + // Should NOT have loaded snapshot again 384 + expect(loadCallsAfter).toBe(loadCallsBefore); 385 + await provider.destroy(); 386 + }); 387 + }); 388 + 389 + // =========================================================================== 390 + // 2. Snapshot loading 391 + // =========================================================================== 392 + describe('Snapshot loading', () => { 393 + it('applies server snapshot to the Y.Doc', async () => { 394 + const state = makeYjsState({ name: 'My Document', content: 'Hello world' }); 395 + const { doc, provider } = await createProvider('load-test', { 396 + ok: true, 397 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 398 + }); 399 + await provider.whenReady; 400 + 401 + expect(doc.getMap('data').get('name')).toBe('My Document'); 402 + expect(doc.getMap('data').get('content')).toBe('Hello world'); 403 + await provider.destroy(); 404 + }); 405 + 406 + it('sets _hadSnapshot = true on successful load', async () => { 407 + const state = makeYjsState({ key: 'value' }); 408 + const { provider } = await createProvider('had-test', { 409 + ok: true, 410 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 411 + }); 412 + await provider.whenReady; 413 + 414 + expect(provider._hadSnapshot).toBe(true); 415 + await provider.destroy(); 416 + }); 417 + 418 + it('does NOT set _hadSnapshot for new docs (404)', async () => { 419 + const { provider } = await createProvider(); 420 + await provider.whenReady; 421 + 422 + expect(provider._hadSnapshot).toBe(false); 423 + await provider.destroy(); 424 + }); 425 + 426 + it('sets _snapshotLoadFailed on non-404 server error', async () => { 427 + fetchResponses['GET:/api/documents/fail-room/snapshot'] = { 428 + ok: false, status: 500, json: () => Promise.resolve({ error: 'fail' }), 429 + }; 430 + const doc = new Y.Doc(); 431 + const provider = new EncryptedProvider(doc, 'fail-room', {} as CryptoKey, { 432 + wsUrl: 'wss://localhost:3000/ws', 433 + apiUrl: '', 434 + }); 435 + await provider.whenReady; 436 + 437 + expect(provider._snapshotLoadFailed).toBe(true); 438 + await provider.destroy(); 439 + }); 440 + 441 + it('does NOT set _snapshotLoadFailed on 404 (new doc)', async () => { 442 + const { provider } = await createProvider(); 443 + await provider.whenReady; 444 + 445 + expect(provider._snapshotLoadFailed).toBe(false); 446 + await provider.destroy(); 447 + }); 448 + 449 + it('rejects too-small snapshots with a warning', async () => { 450 + const tinyData = new Uint8Array([0, 1, 2]); // 3 bytes < MIN_SNAPSHOT_BYTES (10) 451 + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 452 + 453 + const { provider } = await createProvider('tiny-test', { 454 + ok: true, 455 + arrayBuffer: () => Promise.resolve(tinyData.buffer.slice(0)), 456 + }); 457 + await provider.whenReady; 458 + 459 + expect(provider._hadSnapshot).toBe(false); 460 + const snapshotWarns = consoleSpy.mock.calls.filter(c => 461 + String(c[0]).includes('snapshot') || String(c[0]).includes('Snapshot'), 462 + ); 463 + expect(snapshotWarns.length).toBeGreaterThan(0); 464 + consoleSpy.mockRestore(); 465 + await provider.destroy(); 466 + }); 467 + 468 + it('falls back to local backup when server returns data but decrypt fails', async () => { 469 + // Mock crypto.decrypt to throw on the first call (server snapshot) 470 + // then succeed on the second (local backup) 471 + vi.resetModules(); 472 + let decryptCallCount = 0; 473 + vi.doMock('../src/lib/crypto.js', () => ({ 474 + encrypt: vi.fn(async (data: Uint8Array) => data), 475 + decrypt: vi.fn(async (data: Uint8Array) => { 476 + decryptCallCount++; 477 + if (decryptCallCount === 1) throw new Error('Decrypt failed'); 478 + return data; // second call (local backup) succeeds 479 + }), 480 + })); 481 + vi.doMock('../src/lib/local-backup.js', () => ({ 482 + saveLocalBackup: saveLocalBackupSpy, 483 + loadLocalBackup: loadLocalBackupSpy, 484 + })); 485 + 486 + const mod = await import('../src/lib/provider.js'); 487 + 488 + const serverData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); 489 + fetchResponses['GET:/api/documents/decrypt-fail/snapshot'] = { 490 + ok: true, 491 + arrayBuffer: () => Promise.resolve(serverData.buffer.slice(0)), 492 + }; 493 + 494 + // Local backup contains valid data 495 + const backupState = makeYjsState({ source: 'local backup' }); 496 + idbStore['decrypt-fail'] = { encrypted: backupState }; 497 + 498 + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 499 + 500 + const doc = new Y.Doc(); 501 + const provider = new mod.EncryptedProvider(doc, 'decrypt-fail', {} as CryptoKey, { 502 + wsUrl: 'wss://localhost:3000/ws', 503 + apiUrl: '', 504 + }); 505 + await provider.whenReady; 506 + 507 + expect(provider._snapshotLoadFailed).toBe(true); 508 + expect(doc.getMap('data').get('source')).toBe('local backup'); 509 + consoleSpy.mockRestore(); 510 + await provider.destroy(); 511 + }); 512 + 513 + it('loads local backup when server fetch throws a network error', async () => { 514 + vi.resetModules(); 515 + vi.doMock('../src/lib/crypto.js', () => ({ 516 + encrypt: vi.fn(async (data: Uint8Array) => data), 517 + decrypt: vi.fn(async (data: Uint8Array) => data), 518 + })); 519 + vi.doMock('../src/lib/local-backup.js', () => ({ 520 + saveLocalBackup: saveLocalBackupSpy, 521 + loadLocalBackup: loadLocalBackupSpy, 522 + })); 523 + 524 + const mod = await import('../src/lib/provider.js'); 525 + 526 + // Fetch throws (network error) 527 + fetchResponses['GET:/api/documents/net-err/snapshot'] = () => 528 + Promise.reject(new Error('Network error')); 529 + 530 + const backupState = makeYjsState({ source: 'offline backup' }); 531 + idbStore['net-err'] = { encrypted: backupState }; 532 + 533 + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 534 + 535 + const doc = new Y.Doc(); 536 + const provider = new mod.EncryptedProvider(doc, 'net-err', {} as CryptoKey, { 537 + wsUrl: 'wss://localhost:3000/ws', 538 + apiUrl: '', 539 + }); 540 + await provider.whenReady; 541 + 542 + expect(provider._snapshotLoadFailed).toBe(true); 543 + expect(doc.getMap('data').get('source')).toBe('offline backup'); 544 + consoleSpy.mockRestore(); 545 + await provider.destroy(); 546 + }); 547 + }); 548 + 549 + // =========================================================================== 550 + // 3. CRDT race prevention 551 + // =========================================================================== 552 + describe('CRDT race prevention', () => { 553 + it('writing to Y.Doc AFTER whenReady preserves loaded sheet data', async () => { 554 + // Simulate server has sheet data 555 + const serverState = makeSheetsState({ 556 + A1: { v: 'Important Data' }, 557 + B1: { v: 42, s: JSON.stringify({ format: 'number' }) }, 558 + }); 559 + 560 + const { doc, provider } = await createProvider('race-test', { 561 + ok: true, 562 + arrayBuffer: () => Promise.resolve(serverState.buffer.slice(0)), 563 + }); 564 + 565 + // Wait for snapshot to load (like sheets/main.ts does with await provider.whenReady) 566 + await provider.whenReady; 567 + 568 + // NOW write — ensureSheet checks if sheet exists first 569 + const ySheets = doc.getMap('sheets'); 570 + if (!ySheets.has('sheet_0')) { 571 + const sheet = new Y.Map(); 572 + sheet.set('name', 'Sheet 1'); 573 + sheet.set('cells', new Y.Map()); 574 + ySheets.set('sheet_0', sheet); 575 + } 576 + 577 + // Verify: the loaded data is still there 578 + const sheet = ySheets.get('sheet_0') as Y.Map<unknown>; 579 + const cells = sheet.get('cells') as Y.Map<unknown>; 580 + const a1 = cells.get('A1') as Y.Map<unknown>; 581 + expect(a1.get('v')).toBe('Important Data'); 582 + 583 + const b1 = cells.get('B1') as Y.Map<unknown>; 584 + expect(b1.get('v')).toBe(42); 585 + 586 + await provider.destroy(); 587 + }); 588 + 589 + it('writing to Y.Doc WITHOUT awaiting whenReady can cause CRDT conflict', async () => { 590 + // This test documents the bug that whenReady prevents. 591 + // Without the await, ensureSheet creates an empty sheet BEFORE 592 + // the server snapshot loads, and CRDT conflict resolution is non-deterministic. 593 + 594 + const serverState = makeSheetsState({ 595 + A1: { v: 'Will this survive?' }, 596 + }); 597 + 598 + // Create provider but DON'T await whenReady 599 + fetchResponses['GET:/api/documents/race-bad/snapshot'] = { 600 + ok: true, 601 + arrayBuffer: () => Promise.resolve(serverState.buffer.slice(0)), 602 + }; 603 + 604 + const doc = new Y.Doc(); 605 + const provider = new EncryptedProvider(doc, 'race-bad', {} as CryptoKey, { 606 + wsUrl: 'wss://localhost:3000/ws', 607 + apiUrl: '', 608 + }); 609 + 610 + // Write immediately WITHOUT waiting — simulates old code 611 + const ySheets = doc.getMap('sheets'); 612 + const sheet = new Y.Map(); 613 + sheet.set('name', 'Sheet 1'); 614 + sheet.set('cells', new Y.Map()); 615 + sheet.set('colWidths', new Y.Map()); 616 + sheet.set('rowCount', 100); 617 + sheet.set('colCount', 26); 618 + ySheets.set('sheet_0', sheet); 619 + 620 + // Now wait for the snapshot to load 621 + await provider.whenReady; 622 + 623 + // Due to CRDT conflict, the result is non-deterministic: 624 + // either the local empty sheet wins or the server data wins. 625 + // We just verify this test runs without crashing — the point is 626 + // the previous test shows the CORRECT pattern (awaiting whenReady). 627 + const loadedSheet = ySheets.get('sheet_0') as Y.Map<unknown>; 628 + expect(loadedSheet).toBeDefined(); 629 + 630 + await provider.destroy(); 631 + }); 632 + 633 + it('new documents work correctly with ensureSheet after whenReady', async () => { 634 + // For a new doc (404), whenReady resolves with empty doc, 635 + // then ensureSheet creates the default sheet — this should work fine. 636 + const { doc, provider } = await createProvider('new-sheet'); 637 + await provider.whenReady; 638 + 639 + const ySheets = doc.getMap('sheets'); 640 + expect(ySheets.has('sheet_0')).toBe(false); // nothing loaded 641 + 642 + // Create default sheet (like ensureSheet does) 643 + const sheet = new Y.Map(); 644 + sheet.set('name', 'Sheet 1'); 645 + sheet.set('cells', new Y.Map()); 646 + ySheets.set('sheet_0', sheet); 647 + 648 + expect(ySheets.has('sheet_0')).toBe(true); 649 + expect((ySheets.get('sheet_0') as Y.Map<unknown>).get('name')).toBe('Sheet 1'); 650 + 651 + await provider.destroy(); 652 + }); 653 + 654 + it('multiple sheets load correctly when awaiting whenReady', async () => { 655 + // Build state with 3 sheets 656 + const doc0 = new Y.Doc(); 657 + const ySheets0 = doc0.getMap('sheets'); 658 + for (let i = 0; i < 3; i++) { 659 + const sheet = new Y.Map(); 660 + sheet.set('name', `Sheet ${i + 1}`); 661 + const cells = new Y.Map(); 662 + const cell = new Y.Map(); 663 + cell.set('v', `Data in sheet ${i}`); 664 + cells.set('A1', cell); 665 + sheet.set('cells', cells); 666 + ySheets0.set(`sheet_${i}`, sheet); 667 + } 668 + const serverState = Y.encodeStateAsUpdate(doc0); 669 + 670 + const { doc, provider } = await createProvider('multi-sheet', { 671 + ok: true, 672 + arrayBuffer: () => Promise.resolve(serverState.buffer.slice(0)), 673 + }); 674 + await provider.whenReady; 675 + 676 + const ySheets = doc.getMap('sheets'); 677 + expect(ySheets.size).toBe(3); 678 + for (let i = 0; i < 3; i++) { 679 + const sheet = ySheets.get(`sheet_${i}`) as Y.Map<unknown>; 680 + expect(sheet.get('name')).toBe(`Sheet ${i + 1}`); 681 + const cells = sheet.get('cells') as Y.Map<unknown>; 682 + expect((cells.get('A1') as Y.Map<unknown>).get('v')).toBe(`Data in sheet ${i}`); 683 + } 684 + await provider.destroy(); 685 + }); 686 + }); 687 + 688 + // =========================================================================== 689 + // 4. Save flow 690 + // =========================================================================== 691 + describe('Save flow', () => { 692 + it('saves to server via PUT after sync', async () => { 693 + const { provider, ws } = await createProvider(); 694 + syncProvider(ws); 695 + 696 + fetchCalls = []; 697 + await provider._saveSnapshot(); 698 + 699 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 700 + await provider.destroy(); 701 + }); 702 + 703 + it('does NOT save before sync when no snapshot was loaded', async () => { 704 + const { provider } = await createProvider(); 705 + expect(provider.synced).toBe(false); 706 + expect(provider._hadSnapshot).toBe(false); 707 + 708 + fetchCalls = []; 709 + await provider._saveSnapshot(); 710 + 711 + expect(snapshotPutCalls()).toHaveLength(0); 712 + expect(saveLocalBackupSpy).not.toHaveBeenCalled(); 713 + await provider.destroy(); 714 + }); 715 + 716 + it('saves to IDB only when disconnected but _hadSnapshot is true', async () => { 717 + const state = makeYjsState({ key: 'data' }); 718 + const { provider, ws } = await createProvider('offline-test', { 719 + ok: true, 720 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 721 + }); 722 + await provider.whenReady; 723 + expect(provider._hadSnapshot).toBe(true); 724 + 725 + // Sync then disconnect 726 + syncProvider(ws); 727 + ws._simulateClose(); 728 + expect(provider.synced).toBe(false); 729 + 730 + fetchCalls = []; 731 + saveLocalBackupSpy.mockClear(); 732 + await provider._saveSnapshot(); 733 + 734 + // No server PUT (disconnected) 735 + expect(snapshotPutCalls()).toHaveLength(0); 736 + // But IDB save happened 737 + expect(saveLocalBackupSpy).toHaveBeenCalled(); 738 + await provider.destroy(); 739 + }); 740 + 741 + it('blocks concurrent saves via _saveInProgress lock', async () => { 742 + const { provider, ws } = await createProvider(); 743 + syncProvider(ws); 744 + 745 + // Manually engage the lock (simulates a save already in flight) 746 + provider._saveInProgress = true; 747 + 748 + fetchCalls = []; 749 + await provider._saveSnapshot(); 750 + 751 + // Save should have bailed immediately — no fetch calls at all 752 + expect(snapshotPutCalls()).toHaveLength(0); 753 + 754 + provider._saveInProgress = false; 755 + await provider.destroy(); 756 + }); 757 + 758 + it('skips save during import-in-progress', async () => { 759 + const { provider, ws } = await createProvider(); 760 + syncProvider(ws); 761 + 762 + (globalThis as Record<string, unknown>).window = { 763 + ...(globalThis as Record<string, Record<string, unknown>>).window, 764 + __importInProgress: true, 765 + }; 766 + 767 + fetchCalls = []; 768 + await provider._saveSnapshot(); 769 + 770 + expect(snapshotPutCalls()).toHaveLength(0); 771 + 772 + // Clear import flag — saves should work again 773 + (globalThis as Record<string, unknown>).window = { 774 + ...(globalThis as Record<string, Record<string, unknown>>).window, 775 + __importInProgress: false, 776 + }; 777 + await provider._saveSnapshot(); 778 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 779 + await provider.destroy(); 780 + }); 781 + 782 + it('retries on server failure up to MAX_SAVE_RETRIES times', async () => { 783 + vi.useFakeTimers(); 784 + const { provider, ws } = await createProvider(); 785 + syncProvider(ws); 786 + 787 + // Stop periodic save timer so it doesn't interfere with retry count 788 + clearInterval(provider._snapshotTimer!); 789 + 790 + // All server calls fail 791 + fetchResponses['PUT:/api/documents/test-room/snapshot'] = { 792 + ok: false, status: 500, json: () => Promise.resolve({ error: 'Server down' }), 793 + }; 794 + 795 + fetchCalls = []; 796 + const savePromise = provider._saveSnapshot(); 797 + 798 + // Advance past exponential backoff delays (1s + 2s between 3 attempts) 799 + await vi.advanceTimersByTimeAsync(10_000); 800 + await savePromise; 801 + 802 + const puts = snapshotPutCalls(); 803 + expect(puts).toHaveLength(3); // MAX_SAVE_RETRIES = 3 804 + vi.useRealTimers(); 805 + await provider.destroy(); 806 + }); 807 + 808 + it('caches encrypted state in _lastEncrypted after save', async () => { 809 + const { provider, ws } = await createProvider(); 810 + syncProvider(ws); 811 + 812 + expect(provider._lastEncrypted).toBeNull(); 813 + await provider._saveSnapshot(); 814 + expect(provider._lastEncrypted).not.toBeNull(); 815 + await provider.destroy(); 816 + }); 817 + 818 + it('saves to IDB as backup even when server save succeeds', async () => { 819 + const { provider, ws } = await createProvider(); 820 + syncProvider(ws); 821 + 822 + saveLocalBackupSpy.mockClear(); 823 + await provider._saveSnapshot(); 824 + 825 + expect(saveLocalBackupSpy).toHaveBeenCalled(); 826 + await provider.destroy(); 827 + }); 828 + 829 + it('clears _hasUnsavedChanges after successful server save', async () => { 830 + const { doc, provider, ws } = await createProvider(); 831 + syncProvider(ws); 832 + 833 + // Trigger a local edit 834 + doc.getMap('data').set('key', 'value'); 835 + expect(provider._hasUnsavedChanges).toBe(true); 836 + 837 + await provider._saveSnapshot(); 838 + expect(provider._hasUnsavedChanges).toBe(false); 839 + await provider.destroy(); 840 + }); 841 + 842 + it('clears _hasUnsavedChanges after IDB-only save when disconnected', async () => { 843 + const state = makeYjsState({ key: 'data' }); 844 + const { doc, provider, ws } = await createProvider('idb-clear', { 845 + ok: true, 846 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 847 + }); 848 + await provider.whenReady; 849 + syncProvider(ws); 850 + ws._simulateClose(); 851 + 852 + doc.getMap('data').set('newkey', 'newval'); 853 + expect(provider._hasUnsavedChanges).toBe(true); 854 + 855 + await provider._saveSnapshot(); 856 + expect(provider._hasUnsavedChanges).toBe(false); 857 + await provider.destroy(); 858 + }); 859 + 860 + it('sets _hadSnapshot = true after first successful server save', async () => { 861 + const { doc, provider, ws } = await createProvider(); 862 + syncProvider(ws); 863 + 864 + // Add data so state >= MIN_SNAPSHOT_BYTES (10) 865 + doc.getMap('data').set('key', 'value'); 866 + 867 + expect(provider._hadSnapshot).toBe(false); 868 + await provider._saveSnapshot(); 869 + expect(provider._hadSnapshot).toBe(true); 870 + await provider.destroy(); 871 + }); 872 + 873 + it('saves encrypted data matching the encoded Y.Doc state', async () => { 874 + const { doc, provider, ws } = await createProvider(); 875 + syncProvider(ws); 876 + doc.getMap('data').set('key', 'value'); 877 + 878 + let savedBody: unknown; 879 + fetchResponses['PUT:/api/documents/test-room/snapshot'] = () => { 880 + savedBody = fetchCalls[fetchCalls.length - 1]?.opts?.body; 881 + return Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true }) }); 882 + }; 883 + 884 + fetchCalls = []; 885 + await provider._saveSnapshot(); 886 + 887 + // With passthrough crypto, saved body should be raw Yjs state 888 + expect(savedBody).toBeDefined(); 889 + // Verify it's a valid Yjs update by applying to a new doc 890 + const doc2 = new Y.Doc(); 891 + Y.applyUpdate(doc2, new Uint8Array(savedBody as ArrayBuffer)); 892 + expect(doc2.getMap('data').get('key')).toBe('value'); 893 + await provider.destroy(); 894 + }); 895 + }); 896 + 897 + // =========================================================================== 898 + // 5. Debounced save 899 + // =========================================================================== 900 + describe('Debounced save', () => { 901 + it('does not fire before sync when no snapshot loaded', async () => { 902 + vi.useFakeTimers(); 903 + const { provider, ws } = await createProvider(); 904 + ws._simulateOpen(); 905 + 906 + fetchCalls = []; 907 + provider._debouncedSave(); 908 + await vi.advanceTimersByTimeAsync(2000); 909 + 910 + expect(snapshotPutCalls()).toHaveLength(0); 911 + vi.useRealTimers(); 912 + await provider.destroy(); 913 + }); 914 + 915 + it('fires after debounce delay when synced', async () => { 916 + vi.useFakeTimers(); 917 + const { provider, ws } = await createProvider(); 918 + syncProvider(ws); 919 + 920 + fetchCalls = []; 921 + provider._debouncedSave(); 922 + // Before debounce expires — no save yet 923 + await vi.advanceTimersByTimeAsync(200); 924 + expect(snapshotPutCalls()).toHaveLength(0); 925 + 926 + // After debounce (500ms) — save fires 927 + await vi.advanceTimersByTimeAsync(400); 928 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 929 + 930 + vi.useRealTimers(); 931 + await provider.destroy(); 932 + }); 933 + 934 + it('resets debounce timer on rapid calls', async () => { 935 + vi.useFakeTimers(); 936 + const { provider, ws } = await createProvider(); 937 + syncProvider(ws); 938 + 939 + fetchCalls = []; 940 + provider._debouncedSave(); 941 + await vi.advanceTimersByTimeAsync(300); // 300ms in 942 + provider._debouncedSave(); // reset timer 943 + await vi.advanceTimersByTimeAsync(300); // 600ms total, 300ms since reset 944 + // Still within debounce window 945 + expect(snapshotPutCalls()).toHaveLength(0); 946 + 947 + await vi.advanceTimersByTimeAsync(300); // 900ms total, 600ms since reset — past debounce 948 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 949 + 950 + vi.useRealTimers(); 951 + await provider.destroy(); 952 + }); 953 + 954 + it('forces save after MAX_SAVE_WAIT during continuous edits', async () => { 955 + vi.useFakeTimers(); 956 + const { provider, ws } = await createProvider(); 957 + syncProvider(ws); 958 + 959 + fetchCalls = []; 960 + // Simulate continuous rapid edits — call _debouncedSave every 200ms 961 + for (let i = 0; i < 30; i++) { 962 + provider._debouncedSave(); 963 + await vi.advanceTimersByTimeAsync(200); 964 + } 965 + 966 + // After 6000ms of continuous edits, at least one forced save should have fired 967 + // (MAX_SAVE_WAIT = 5000) 968 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 969 + 970 + vi.useRealTimers(); 971 + await provider.destroy(); 972 + }); 973 + 974 + it('fires when disconnected but _hadSnapshot is true', async () => { 975 + vi.useFakeTimers(); 976 + const state = makeYjsState({ key: 'data' }); 977 + const { provider, ws } = await createProvider('debounce-offline', { 978 + ok: true, 979 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 980 + }); 981 + await provider.whenReady; 982 + syncProvider(ws); 983 + ws._simulateClose(); 984 + expect(provider._hadSnapshot).toBe(true); 985 + expect(provider.synced).toBe(false); 986 + 987 + saveLocalBackupSpy.mockClear(); 988 + provider._debouncedSave(); 989 + await vi.advanceTimersByTimeAsync(1000); 990 + 991 + // Should have saved to IDB (not server) 992 + expect(saveLocalBackupSpy).toHaveBeenCalled(); 993 + 994 + vi.useRealTimers(); 995 + await provider.destroy(); 996 + }); 997 + }); 998 + 999 + // =========================================================================== 1000 + // 6. Emergency save (beforeunload / pagehide) 1001 + // =========================================================================== 1002 + describe('Emergency save', () => { 1003 + it('fires sendBeacon with cached encrypted state on beforeunload', async () => { 1004 + const { doc, provider, ws } = await createProvider(); 1005 + syncProvider(ws); 1006 + 1007 + // Add data so state >= MIN_SNAPSHOT_BYTES, which sets _hadSnapshot after save 1008 + doc.getMap('data').set('key', 'value'); 1009 + 1010 + // Do a normal save to populate _lastEncrypted and _hadSnapshot 1011 + await provider._saveSnapshot(); 1012 + expect(provider._lastEncrypted).not.toBeNull(); 1013 + expect(provider._hadSnapshot).toBe(true); 1014 + 1015 + // Mark as having unsaved changes 1016 + provider._hasUnsavedChanges = true; 1017 + 1018 + sendBeaconCalls = []; 1019 + provider._handleBeforeUnload({} as BeforeUnloadEvent); 1020 + 1021 + // sendBeacon should have been called 1022 + expect(sendBeaconCalls.length).toBeGreaterThan(0); 1023 + expect(sendBeaconCalls[0]!.url).toContain('/snapshot'); 1024 + await provider.destroy(); 1025 + }); 1026 + 1027 + it('skips entirely when no unsaved changes and cached state exists', async () => { 1028 + const { provider, ws } = await createProvider(); 1029 + syncProvider(ws); 1030 + 1031 + await provider._saveSnapshot(); 1032 + // _hasUnsavedChanges is false after successful save, _lastEncrypted is set 1033 + expect(provider._hasUnsavedChanges).toBe(false); 1034 + expect(provider._lastEncrypted).not.toBeNull(); 1035 + 1036 + sendBeaconCalls = []; 1037 + provider._handleBeforeUnload({} as BeforeUnloadEvent); 1038 + 1039 + // Should skip — nothing to save 1040 + expect(sendBeaconCalls).toHaveLength(0); 1041 + await provider.destroy(); 1042 + }); 1043 + 1044 + it('attempts fresh encrypt + sendBeacon after cached beacon', async () => { 1045 + const { provider, ws } = await createProvider(); 1046 + syncProvider(ws); 1047 + 1048 + await provider._saveSnapshot(); 1049 + provider._hasUnsavedChanges = true; 1050 + provider._hadSnapshot = true; 1051 + 1052 + sendBeaconCalls = []; 1053 + provider._handleBeforeUnload({} as BeforeUnloadEvent); 1054 + 1055 + // Give async encrypt a tick 1056 + await new Promise(r => setTimeout(r, 50)); 1057 + 1058 + // At least 1 sendBeacon (cached), possibly 2 (fresh) 1059 + expect(sendBeaconCalls.length).toBeGreaterThanOrEqual(1); 1060 + await provider.destroy(); 1061 + }); 1062 + 1063 + it('saves to IDB during emergency save', async () => { 1064 + const { doc, provider, ws } = await createProvider(); 1065 + syncProvider(ws); 1066 + 1067 + // Add data so state >= MIN_SNAPSHOT_BYTES, setting _hadSnapshot after save 1068 + doc.getMap('data').set('key', 'value'); 1069 + await provider._saveSnapshot(); 1070 + expect(provider._hadSnapshot).toBe(true); 1071 + 1072 + provider._hasUnsavedChanges = true; 1073 + 1074 + saveLocalBackupSpy.mockClear(); 1075 + provider._handleBeforeUnload({} as BeforeUnloadEvent); 1076 + 1077 + // Give async encrypt→IDB path time to complete 1078 + await new Promise(r => setTimeout(r, 50)); 1079 + 1080 + expect(saveLocalBackupSpy).toHaveBeenCalled(); 1081 + await provider.destroy(); 1082 + }); 1083 + 1084 + it('does not call event.preventDefault', async () => { 1085 + const { provider } = await createProvider(); 1086 + 1087 + const event = { preventDefault: vi.fn() } as unknown as BeforeUnloadEvent; 1088 + provider._handleBeforeUnload(event); 1089 + 1090 + expect(event.preventDefault).not.toHaveBeenCalled(); 1091 + await provider.destroy(); 1092 + }); 1093 + 1094 + it('handles missing sendBeacon gracefully', async () => { 1095 + // Remove sendBeacon from navigator 1096 + (globalThis as Record<string, unknown>).navigator = {}; 1097 + 1098 + const { provider, ws } = await createProvider(); 1099 + syncProvider(ws); 1100 + await provider._saveSnapshot(); 1101 + provider._hasUnsavedChanges = true; 1102 + 1103 + // Should not throw 1104 + expect(() => provider._handleBeforeUnload({} as BeforeUnloadEvent)).not.toThrow(); 1105 + await provider.destroy(); 1106 + }); 1107 + }); 1108 + 1109 + // =========================================================================== 1110 + // 7. Periodic saves 1111 + // =========================================================================== 1112 + describe('Periodic saves', () => { 1113 + it('starts periodic timer after sync', async () => { 1114 + vi.useFakeTimers(); 1115 + const { provider, ws } = await createProvider(); 1116 + syncProvider(ws); 1117 + 1118 + fetchCalls = []; 1119 + await vi.advanceTimersByTimeAsync(25_000); 1120 + 1121 + // At 5s interval, 25s should produce ~5 saves 1122 + const puts = snapshotPutCalls(); 1123 + expect(puts.length).toBeGreaterThanOrEqual(4); 1124 + 1125 + vi.useRealTimers(); 1126 + await provider.destroy(); 1127 + }); 1128 + 1129 + it('does NOT start periodic timer before sync', async () => { 1130 + vi.useFakeTimers(); 1131 + const { provider, ws } = await createProvider(); 1132 + ws._simulateOpen(); 1133 + // NOT synced 1134 + 1135 + fetchCalls = []; 1136 + await vi.advanceTimersByTimeAsync(30_000); 1137 + 1138 + expect(snapshotPutCalls()).toHaveLength(0); 1139 + 1140 + vi.useRealTimers(); 1141 + await provider.destroy(); 1142 + }); 1143 + 1144 + it('clears timer on disconnect', async () => { 1145 + vi.useFakeTimers(); 1146 + const { provider, ws } = await createProvider(); 1147 + syncProvider(ws); 1148 + 1149 + fetchCalls = []; 1150 + await vi.advanceTimersByTimeAsync(12_000); // ~2 saves 1151 + const putsBeforeDisconnect = snapshotPutCalls().length; 1152 + expect(putsBeforeDisconnect).toBeGreaterThan(0); 1153 + 1154 + ws._simulateClose(); 1155 + fetchCalls = []; 1156 + await vi.advanceTimersByTimeAsync(20_000); 1157 + 1158 + // No new server PUTs after disconnect 1159 + expect(snapshotPutCalls()).toHaveLength(0); 1160 + 1161 + vi.useRealTimers(); 1162 + await provider.destroy(); 1163 + }); 1164 + 1165 + it('restarts timer on re-sync after reconnect', async () => { 1166 + vi.useFakeTimers(); 1167 + const { provider, ws } = await createProvider(); 1168 + syncProvider(ws); 1169 + 1170 + // Let timer fire once 1171 + fetchCalls = []; 1172 + await vi.advanceTimersByTimeAsync(6000); 1173 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 1174 + 1175 + // Disconnect 1176 + ws._simulateClose(); 1177 + 1178 + // Reconnect 1179 + await vi.advanceTimersByTimeAsync(5000); 1180 + const ws2 = MockWebSocket.latest!; 1181 + ws2._simulateOpen(); 1182 + ws2._simulateMessage(JSON.stringify({ type: 'peer-count', count: 0 })); 1183 + 1184 + // Timer should restart 1185 + fetchCalls = []; 1186 + await vi.advanceTimersByTimeAsync(12_000); 1187 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 1188 + 1189 + vi.useRealTimers(); 1190 + await provider.destroy(); 1191 + }); 1192 + }); 1193 + 1194 + // =========================================================================== 1195 + // 8. Reconnection 1196 + // =========================================================================== 1197 + describe('Reconnection', () => { 1198 + it('preserves doc state across reconnect', async () => { 1199 + const state = makeYjsState({ key: 'persistent' }); 1200 + const { doc, provider, ws } = await createProvider('reconnect-test', { 1201 + ok: true, 1202 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 1203 + }); 1204 + await provider.whenReady; 1205 + syncProvider(ws); 1206 + 1207 + // Add local data 1208 + doc.getMap('data').set('local', 'edit'); 1209 + 1210 + // Disconnect + reconnect 1211 + ws._simulateClose(); 1212 + await vi.waitFor(() => { 1213 + expect(MockWebSocket._instances.length).toBeGreaterThan(1); 1214 + }, { timeout: 5000 }); 1215 + 1216 + // Data should still be there 1217 + expect(doc.getMap('data').get('key')).toBe('persistent'); 1218 + expect(doc.getMap('data').get('local')).toBe('edit'); 1219 + await provider.destroy(); 1220 + }); 1221 + 1222 + it('synced resets to false on disconnect', async () => { 1223 + const { provider, ws } = await createProvider(); 1224 + syncProvider(ws); 1225 + expect(provider.synced).toBe(true); 1226 + 1227 + ws._simulateClose(); 1228 + expect(provider.synced).toBe(false); 1229 + await provider.destroy(); 1230 + }); 1231 + 1232 + it('triggers save on re-sync when there are unsaved changes', async () => { 1233 + vi.useFakeTimers(); 1234 + const { doc, provider, ws } = await createProvider(); 1235 + syncProvider(ws); 1236 + 1237 + // Make an edit 1238 + doc.getMap('data').set('key', 'value'); 1239 + 1240 + // Disconnect 1241 + ws._simulateClose(); 1242 + 1243 + // Wait for reconnect 1244 + await vi.advanceTimersByTimeAsync(5000); 1245 + const ws2 = MockWebSocket.latest!; 1246 + 1247 + fetchCalls = []; 1248 + // Re-sync 1249 + ws2._simulateOpen(); 1250 + ws2._simulateMessage(JSON.stringify({ type: 'peer-count', count: 0 })); 1251 + 1252 + // The _onSynced handler should trigger a save due to _hasUnsavedChanges 1253 + await vi.advanceTimersByTimeAsync(1000); 1254 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 1255 + 1256 + vi.useRealTimers(); 1257 + await provider.destroy(); 1258 + }); 1259 + 1260 + it('emits sync event on reconnection', async () => { 1261 + vi.useFakeTimers(); 1262 + const syncSpy = vi.fn(); 1263 + const { provider, ws } = await createProvider(); 1264 + provider.on('sync', syncSpy); 1265 + syncProvider(ws); 1266 + expect(syncSpy).toHaveBeenCalledTimes(1); 1267 + 1268 + // Disconnect + reconnect 1269 + ws._simulateClose(); 1270 + await vi.advanceTimersByTimeAsync(5000); 1271 + const ws2 = MockWebSocket.latest!; 1272 + ws2._simulateOpen(); 1273 + ws2._simulateMessage(JSON.stringify({ type: 'peer-count', count: 0 })); 1274 + 1275 + expect(syncSpy).toHaveBeenCalledTimes(2); 1276 + 1277 + vi.useRealTimers(); 1278 + await provider.destroy(); 1279 + }); 1280 + 1281 + it('reconnects automatically after disconnect', async () => { 1282 + vi.useFakeTimers(); 1283 + const { provider, ws } = await createProvider(); 1284 + syncProvider(ws); 1285 + 1286 + const instancesBefore = MockWebSocket._instances.length; 1287 + ws._simulateClose(); 1288 + 1289 + // Should create a new WS after 2-4 seconds 1290 + await vi.advanceTimersByTimeAsync(5000); 1291 + expect(MockWebSocket._instances.length).toBeGreaterThan(instancesBefore); 1292 + 1293 + vi.useRealTimers(); 1294 + await provider.destroy(); 1295 + }); 1296 + }); 1297 + 1298 + // =========================================================================== 1299 + // 9. Version history 1300 + // =========================================================================== 1301 + describe('Version history', () => { 1302 + it('creates a version on save when _hadSnapshot is true', async () => { 1303 + const state = makeYjsState({ key: 'data' }); 1304 + const { provider, ws } = await createProvider('version-test', { 1305 + ok: true, 1306 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 1307 + }); 1308 + await provider.whenReady; 1309 + syncProvider(ws); 1310 + 1311 + fetchCalls = []; 1312 + await provider._saveSnapshot(); 1313 + 1314 + expect(versionPostCalls().length).toBeGreaterThan(0); 1315 + await provider.destroy(); 1316 + }); 1317 + 1318 + it('does NOT create a version for new docs (_hadSnapshot false)', async () => { 1319 + const { provider, ws } = await createProvider(); 1320 + syncProvider(ws); 1321 + 1322 + fetchCalls = []; 1323 + await provider._saveSnapshot(); 1324 + 1325 + expect(versionPostCalls()).toHaveLength(0); 1326 + await provider.destroy(); 1327 + }); 1328 + 1329 + it('throttles version creation to 5 minute intervals', async () => { 1330 + const state = makeYjsState({ key: 'data' }); 1331 + const { provider, ws } = await createProvider('version-throttle', { 1332 + ok: true, 1333 + arrayBuffer: () => Promise.resolve(state.buffer.slice(0)), 1334 + }); 1335 + await provider.whenReady; 1336 + syncProvider(ws); 1337 + 1338 + // First save — creates version 1339 + fetchCalls = []; 1340 + await provider._saveSnapshot(); 1341 + expect(versionPostCalls()).toHaveLength(1); 1342 + 1343 + // Second save immediately — no new version (throttled) 1344 + fetchCalls = []; 1345 + await provider._saveSnapshot(); 1346 + expect(versionPostCalls()).toHaveLength(0); 1347 + 1348 + await provider.destroy(); 1349 + }); 1350 + }); 1351 + 1352 + // =========================================================================== 1353 + // 10. Destroy lifecycle 1354 + // =========================================================================== 1355 + describe('Destroy lifecycle', () => { 1356 + it('saves snapshot before closing', async () => { 1357 + const { provider, ws } = await createProvider(); 1358 + syncProvider(ws); 1359 + 1360 + fetchCalls = []; 1361 + await provider.destroy(); 1362 + 1363 + // destroy() calls _saveSnapshot() 1364 + expect(snapshotPutCalls().length).toBeGreaterThan(0); 1365 + }); 1366 + 1367 + it('removes event listeners', async () => { 1368 + const removeListenerSpy = vi.fn(); 1369 + (globalThis as Record<string, unknown>).window = { 1370 + addEventListener: vi.fn(), 1371 + removeEventListener: removeListenerSpy, 1372 + __importInProgress: false, 1373 + }; 1374 + 1375 + const { provider, ws } = await createProvider(); 1376 + syncProvider(ws); 1377 + await provider.destroy(); 1378 + 1379 + // Should have removed beforeunload and pagehide listeners 1380 + const removedEvents = removeListenerSpy.mock.calls.map(c => c[0]); 1381 + expect(removedEvents).toContain('beforeunload'); 1382 + expect(removedEvents).toContain('pagehide'); 1383 + }); 1384 + 1385 + it('sets _destroyed flag to prevent reconnection', async () => { 1386 + const { provider, ws } = await createProvider(); 1387 + syncProvider(ws); 1388 + 1389 + await provider.destroy(); 1390 + expect(provider._destroyed).toBe(true); 1391 + }); 1392 + 1393 + it('closes websocket', async () => { 1394 + const { provider, ws } = await createProvider(); 1395 + syncProvider(ws); 1396 + 1397 + await provider.destroy(); 1398 + expect(ws.readyState).toBe(MockWebSocket.CLOSED); 1399 + }); 1400 + 1401 + it('resolves whenReady on destroy', async () => { 1402 + const doc = new Y.Doc(); 1403 + const provider = new EncryptedProvider(doc, 'destroy-ready', {} as CryptoKey, { 1404 + wsUrl: 'wss://localhost:3000/ws', 1405 + apiUrl: '', 1406 + }); 1407 + 1408 + const readyBefore = await Promise.race([ 1409 + provider.whenReady.then(() => true), 1410 + new Promise(r => setTimeout(() => r(false), 10)), 1411 + ]); 1412 + 1413 + await provider.destroy(); 1414 + 1415 + // After destroy, whenReady should be resolved 1416 + const readyAfter = await Promise.race([ 1417 + provider.whenReady.then(() => true), 1418 + new Promise(r => setTimeout(() => r(false), 50)), 1419 + ]); 1420 + expect(readyAfter).toBe(true); 1421 + }); 1422 + }); 1423 + 1424 + // =========================================================================== 1425 + // 11. Save status transitions 1426 + // =========================================================================== 1427 + describe('Save status transitions', () => { 1428 + it('transitions: saved → saving → saved on success', async () => { 1429 + const statuses: string[] = []; 1430 + const { provider, ws } = await createProvider(); 1431 + provider.on('save-status', (p) => statuses.push(p.status)); 1432 + syncProvider(ws); 1433 + 1434 + expect(provider.saveStatus).toBe('saved'); 1435 + await provider._saveSnapshot(); 1436 + 1437 + expect(statuses).toContain('saving'); 1438 + expect(statuses[statuses.length - 1]).toBe('saved'); 1439 + await provider.destroy(); 1440 + }); 1441 + 1442 + it('transitions: saved → saving → error on total failure', async () => { 1443 + vi.useFakeTimers(); 1444 + const statuses: string[] = []; 1445 + const { provider, ws } = await createProvider(); 1446 + provider.on('save-status', (p) => statuses.push(p.status)); 1447 + syncProvider(ws); 1448 + clearInterval(provider._snapshotTimer!); 1449 + 1450 + fetchResponses['PUT:/api/documents/test-room/snapshot'] = { 1451 + ok: false, status: 500, 1452 + }; 1453 + // Also make IDB fail 1454 + saveLocalBackupSpy.mockRejectedValueOnce(new Error('IDB fail')); 1455 + 1456 + const savePromise = provider._saveSnapshot(); 1457 + await vi.advanceTimersByTimeAsync(10_000); 1458 + await savePromise; 1459 + 1460 + expect(statuses).toContain('saving'); 1461 + expect(statuses[statuses.length - 1]).toBe('error'); 1462 + vi.useRealTimers(); 1463 + await provider.destroy(); 1464 + }); 1465 + 1466 + it('shows error when server fails but IDB succeeds', async () => { 1467 + vi.useFakeTimers(); 1468 + const statuses: string[] = []; 1469 + const { provider, ws } = await createProvider(); 1470 + provider.on('save-status', (p) => statuses.push(p.status)); 1471 + syncProvider(ws); 1472 + clearInterval(provider._snapshotTimer!); 1473 + 1474 + fetchResponses['PUT:/api/documents/test-room/snapshot'] = { 1475 + ok: false, status: 500, 1476 + }; 1477 + 1478 + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); 1479 + const savePromise = provider._saveSnapshot(); 1480 + await vi.advanceTimersByTimeAsync(10_000); 1481 + await savePromise; 1482 + 1483 + // Server failed but IDB succeeded — status should be error 1484 + // (user should know server save failed) 1485 + expect(statuses[statuses.length - 1]).toBe('error'); 1486 + consoleSpy.mockRestore(); 1487 + vi.useRealTimers(); 1488 + await provider.destroy(); 1489 + }); 1490 + 1491 + it('status is saved (not stuck on saving) when save bails on import', async () => { 1492 + const { provider, ws } = await createProvider(); 1493 + syncProvider(ws); 1494 + 1495 + (globalThis as Record<string, unknown>).window = { 1496 + ...(globalThis as Record<string, Record<string, unknown>>).window, 1497 + __importInProgress: true, 1498 + }; 1499 + 1500 + await provider._saveSnapshot(); 1501 + // Status should not be stuck on 'saving' 1502 + expect(provider.saveStatus).toBe('saved'); 1503 + await provider.destroy(); 1504 + }); 1505 + 1506 + it('emits save-status events that UI can subscribe to', async () => { 1507 + const events: Array<{ status: string }> = []; 1508 + const { provider, ws } = await createProvider(); 1509 + provider.on('save-status', (p) => events.push(p)); 1510 + syncProvider(ws); 1511 + 1512 + await provider._saveSnapshot(); 1513 + 1514 + expect(events.length).toBeGreaterThan(0); 1515 + expect(events.some(e => e.status === 'saving')).toBe(true); 1516 + expect(events.some(e => e.status === 'saved')).toBe(true); 1517 + await provider.destroy(); 1518 + }); 1519 + }); 1520 + 1521 + // =========================================================================== 1522 + // 12. Multi-sheet Yjs round-trip integrity 1523 + // =========================================================================== 1524 + describe('Multi-sheet Yjs round-trip integrity', () => { 1525 + it('preserves multiple sheets with different cell data', () => { 1526 + const doc = new Y.Doc(); 1527 + const ySheets = doc.getMap('sheets'); 1528 + 1529 + // Sheet 0 1530 + const sheet0 = new Y.Map(); 1531 + sheet0.set('name', 'Revenue'); 1532 + const cells0 = new Y.Map(); 1533 + const a1 = new Y.Map(); 1534 + a1.set('v', 'Q1'); 1535 + a1.set('f', ''); 1536 + cells0.set('A1', a1); 1537 + const b1 = new Y.Map(); 1538 + b1.set('v', 50000); 1539 + b1.set('s', JSON.stringify({ format: 'currency' })); 1540 + cells0.set('B1', b1); 1541 + sheet0.set('cells', cells0); 1542 + ySheets.set('sheet_0', sheet0); 1543 + 1544 + // Sheet 1 1545 + const sheet1 = new Y.Map(); 1546 + sheet1.set('name', 'Expenses'); 1547 + const cells1 = new Y.Map(); 1548 + const a1s1 = new Y.Map(); 1549 + a1s1.set('v', 'Rent'); 1550 + cells1.set('A1', a1s1); 1551 + const b1s1 = new Y.Map(); 1552 + b1s1.set('v', 2000); 1553 + b1s1.set('f', '=Revenue!B1*0.04'); 1554 + cells1.set('B1', b1s1); 1555 + sheet1.set('cells', cells1); 1556 + ySheets.set('sheet_1', sheet1); 1557 + 1558 + // Encode → decode 1559 + const state = Y.encodeStateAsUpdate(doc); 1560 + const doc2 = new Y.Doc(); 1561 + Y.applyUpdate(doc2, state); 1562 + const ySheets2 = doc2.getMap('sheets'); 1563 + 1564 + expect(ySheets2.size).toBe(2); 1565 + 1566 + const s0 = ySheets2.get('sheet_0') as Y.Map<unknown>; 1567 + expect(s0.get('name')).toBe('Revenue'); 1568 + const c0 = s0.get('cells') as Y.Map<unknown>; 1569 + expect((c0.get('A1') as Y.Map<unknown>).get('v')).toBe('Q1'); 1570 + expect((c0.get('B1') as Y.Map<unknown>).get('v')).toBe(50000); 1571 + 1572 + const s1 = ySheets2.get('sheet_1') as Y.Map<unknown>; 1573 + expect(s1.get('name')).toBe('Expenses'); 1574 + const c1 = s1.get('cells') as Y.Map<unknown>; 1575 + expect((c1.get('A1') as Y.Map<unknown>).get('v')).toBe('Rent'); 1576 + expect((c1.get('B1') as Y.Map<unknown>).get('f')).toBe('=Revenue!B1*0.04'); 1577 + }); 1578 + 1579 + it('preserves sheet metadata (colWidths, rowCount, freezeRows)', () => { 1580 + const doc = new Y.Doc(); 1581 + const ySheets = doc.getMap('sheets'); 1582 + const sheet = new Y.Map(); 1583 + sheet.set('name', 'Config'); 1584 + sheet.set('rowCount', 200); 1585 + sheet.set('colCount', 52); 1586 + sheet.set('freezeRows', 2); 1587 + sheet.set('freezeCols', 1); 1588 + const widths = new Y.Map(); 1589 + widths.set('0', 150); 1590 + widths.set('3', 200); 1591 + sheet.set('colWidths', widths); 1592 + sheet.set('cells', new Y.Map()); 1593 + ySheets.set('sheet_0', sheet); 1594 + 1595 + const state = Y.encodeStateAsUpdate(doc); 1596 + const doc2 = new Y.Doc(); 1597 + Y.applyUpdate(doc2, state); 1598 + 1599 + const s = doc2.getMap('sheets').get('sheet_0') as Y.Map<unknown>; 1600 + expect(s.get('name')).toBe('Config'); 1601 + expect(s.get('rowCount')).toBe(200); 1602 + expect(s.get('colCount')).toBe(52); 1603 + expect(s.get('freezeRows')).toBe(2); 1604 + expect(s.get('freezeCols')).toBe(1); 1605 + expect((s.get('colWidths') as Y.Map<unknown>).get('0')).toBe(150); 1606 + expect((s.get('colWidths') as Y.Map<unknown>).get('3')).toBe(200); 1607 + }); 1608 + 1609 + it('preserves conditional formatting and validation rules', () => { 1610 + const doc = new Y.Doc(); 1611 + const ySheets = doc.getMap('sheets'); 1612 + const sheet = new Y.Map(); 1613 + sheet.set('name', 'Sheet 1'); 1614 + sheet.set('cells', new Y.Map()); 1615 + 1616 + // CF rules 1617 + const cfRules = new Y.Array(); 1618 + cfRules.push([JSON.stringify({ range: 'A1:A10', type: 'greaterThan', value: 100, bg: '#ff0000' })]); 1619 + sheet.set('cfRules', cfRules); 1620 + 1621 + // Validation rules 1622 + const validations = new Y.Map(); 1623 + validations.set('B1:B10', JSON.stringify({ type: 'list', items: 'Yes,No,Maybe' })); 1624 + sheet.set('validations', validations); 1625 + 1626 + ySheets.set('sheet_0', sheet); 1627 + 1628 + const state = Y.encodeStateAsUpdate(doc); 1629 + const doc2 = new Y.Doc(); 1630 + Y.applyUpdate(doc2, state); 1631 + 1632 + const s = doc2.getMap('sheets').get('sheet_0') as Y.Map<unknown>; 1633 + const cf = s.get('cfRules') as Y.Array<string>; 1634 + expect(cf.length).toBe(1); 1635 + expect(JSON.parse(cf.get(0)).type).toBe('greaterThan'); 1636 + 1637 + const val = s.get('validations') as Y.Map<string>; 1638 + expect(JSON.parse(val.get('B1:B10')!).type).toBe('list'); 1639 + }); 1640 + 1641 + it('handles 10,000 cells across 5 sheets without data loss', () => { 1642 + const doc = new Y.Doc(); 1643 + const ySheets = doc.getMap('sheets'); 1644 + 1645 + doc.transact(() => { 1646 + for (let sheetIdx = 0; sheetIdx < 5; sheetIdx++) { 1647 + const sheet = new Y.Map(); 1648 + sheet.set('name', `Sheet ${sheetIdx + 1}`); 1649 + const cells = new Y.Map(); 1650 + for (let i = 0; i < 2000; i++) { 1651 + const cell = new Y.Map(); 1652 + cell.set('v', `s${sheetIdx}-c${i}`); 1653 + cells.set(`cell-${i}`, cell); 1654 + } 1655 + sheet.set('cells', cells); 1656 + ySheets.set(`sheet_${sheetIdx}`, sheet); 1657 + } 1658 + }); 1659 + 1660 + const state = Y.encodeStateAsUpdate(doc); 1661 + const doc2 = new Y.Doc(); 1662 + Y.applyUpdate(doc2, state); 1663 + const ySheets2 = doc2.getMap('sheets'); 1664 + 1665 + expect(ySheets2.size).toBe(5); 1666 + for (let sheetIdx = 0; sheetIdx < 5; sheetIdx++) { 1667 + const sheet = ySheets2.get(`sheet_${sheetIdx}`) as Y.Map<unknown>; 1668 + const cells = sheet.get('cells') as Y.Map<unknown>; 1669 + expect(cells.size).toBe(2000); 1670 + // Spot check 1671 + expect((cells.get('cell-0') as Y.Map<unknown>).get('v')).toBe(`s${sheetIdx}-c0`); 1672 + expect((cells.get('cell-999') as Y.Map<unknown>).get('v')).toBe(`s${sheetIdx}-c999`); 1673 + expect((cells.get('cell-1999') as Y.Map<unknown>).get('v')).toBe(`s${sheetIdx}-c1999`); 1674 + } 1675 + }); 1676 + }); 1677 + 1678 + // =========================================================================== 1679 + // 13. Doc update propagation 1680 + // =========================================================================== 1681 + describe('Doc update propagation', () => { 1682 + it('sends encrypted update to WebSocket on local doc change', async () => { 1683 + const { doc, provider, ws } = await createProvider(); 1684 + syncProvider(ws); 1685 + 1686 + const sentBefore = ws._sent.length; 1687 + doc.getMap('data').set('key', 'value'); 1688 + 1689 + // Update triggers async _sendMessage — give it a tick 1690 + await new Promise(r => setTimeout(r, 10)); 1691 + expect(ws._sent.length).toBeGreaterThan(sentBefore); 1692 + await provider.destroy(); 1693 + }); 1694 + 1695 + it('does NOT echo back updates received from peers', async () => { 1696 + const { doc, provider, ws } = await createProvider(); 1697 + syncProvider(ws); 1698 + 1699 + // Let pending async messages (sync step 1, awareness) settle 1700 + await new Promise(r => setTimeout(r, 10)); 1701 + 1702 + // Simulate receiving an update from a peer (origin = provider) 1703 + const peerDoc = new Y.Doc(); 1704 + peerDoc.getMap('data').set('peer-key', 'peer-value'); 1705 + const peerUpdate = Y.encodeStateAsUpdate(peerDoc); 1706 + 1707 + const sentBefore = ws._sent.length; 1708 + Y.applyUpdate(doc, peerUpdate, provider); 1709 + 1710 + await new Promise(r => setTimeout(r, 10)); 1711 + // Should NOT have sent anything — the update came from us (the provider) 1712 + expect(ws._sent.length).toBe(sentBefore); 1713 + await provider.destroy(); 1714 + }); 1715 + 1716 + it('marks _hasUnsavedChanges on local edit', async () => { 1717 + const { doc, provider, ws } = await createProvider(); 1718 + syncProvider(ws); 1719 + 1720 + expect(provider._hasUnsavedChanges).toBe(false); 1721 + doc.getMap('data').set('key', 'value'); 1722 + expect(provider._hasUnsavedChanges).toBe(true); 1723 + await provider.destroy(); 1724 + }); 1725 + 1726 + it('does NOT mark _hasUnsavedChanges on peer update', async () => { 1727 + const { doc, provider, ws } = await createProvider(); 1728 + syncProvider(ws); 1729 + 1730 + expect(provider._hasUnsavedChanges).toBe(false); 1731 + 1732 + // Apply update as if from peer (origin = provider) 1733 + const peerDoc = new Y.Doc(); 1734 + peerDoc.getMap('data').set('peer-key', 'peer-value'); 1735 + Y.applyUpdate(doc, Y.encodeStateAsUpdate(peerDoc), provider); 1736 + 1737 + expect(provider._hasUnsavedChanges).toBe(false); 1738 + await provider.destroy(); 1739 + }); 1740 + }); 1741 + 1742 + // =========================================================================== 1743 + // 14. Connection status events 1744 + // =========================================================================== 1745 + describe('Connection status events', () => { 1746 + it('emits connected status on WS open', async () => { 1747 + const statuses: boolean[] = []; 1748 + const { provider, ws } = await createProvider(); 1749 + provider.on('status', (p) => statuses.push(p.connected)); 1750 + 1751 + ws._simulateOpen(); 1752 + expect(statuses).toContain(true); 1753 + await provider.destroy(); 1754 + }); 1755 + 1756 + it('emits disconnected status on WS close', async () => { 1757 + const statuses: boolean[] = []; 1758 + const { provider, ws } = await createProvider(); 1759 + provider.on('status', (p) => statuses.push(p.connected)); 1760 + 1761 + ws._simulateOpen(); 1762 + ws._simulateClose(); 1763 + expect(statuses).toContain(false); 1764 + await provider.destroy(); 1765 + }); 1766 + 1767 + it('connected is false initially', async () => { 1768 + const { provider } = await createProvider(); 1769 + // Before WS open, connected should be false 1770 + // (after WS is created but before onopen fires) 1771 + expect(provider.connected).toBe(false); 1772 + await provider.destroy(); 1773 + }); 1774 + });
+47
tests/provider-save.test.ts
··· 197 197 expect(interval).toBeLessThanOrEqual(5000); 198 198 }); 199 199 }); 200 + 201 + describe('whenReady promise for snapshot preload', () => { 202 + it('should expose a whenReady promise that resolves after snapshot load', async () => { 203 + const source = await import('fs').then(fs => 204 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 205 + ); 206 + // Provider must expose whenReady for consumers to await before writing to ydoc 207 + expect(source).toContain('whenReady: Promise<void>'); 208 + // whenReady must be resolved after _loadSnapshot completes 209 + expect(source).toContain('_resolveReady()'); 210 + }); 211 + 212 + it('should resolve whenReady even if destroy is called early', async () => { 213 + const source = await import('fs').then(fs => 214 + fs.readFileSync('src/lib/provider.ts', 'utf-8') 215 + ); 216 + const destroyBlock = source.match(/async destroy\(\)[\s\S]*?\n \}/)?.[0] || ''; 217 + expect(destroyBlock).toContain('_resolveReady()'); 218 + }); 219 + }); 220 + }); 221 + 222 + describe('Sheets must await whenReady before ydoc writes', () => { 223 + it('should await provider.whenReady before ensureSheet', async () => { 224 + const source = await import('fs').then(fs => 225 + fs.readFileSync('src/sheets/main.ts', 'utf-8') 226 + ); 227 + // whenReady must appear BEFORE ensureSheet in the source 228 + const readyIdx = source.indexOf('await provider.whenReady'); 229 + const ensureIdx = source.indexOf('ensureSheet(0)'); 230 + expect(readyIdx).toBeGreaterThan(-1); 231 + expect(ensureIdx).toBeGreaterThan(-1); 232 + expect(readyIdx).toBeLessThan(ensureIdx); 233 + }); 234 + }); 235 + 236 + describe('Docs must await whenReady before editor creation', () => { 237 + it('should await provider.whenReady before new Editor', async () => { 238 + const source = await import('fs').then(fs => 239 + fs.readFileSync('src/docs/main.ts', 'utf-8') 240 + ); 241 + const readyIdx = source.indexOf('await provider.whenReady'); 242 + const editorIdx = source.indexOf('new Editor('); 243 + expect(readyIdx).toBeGreaterThan(-1); 244 + expect(editorIdx).toBeGreaterThan(-1); 245 + expect(readyIdx).toBeLessThan(editorIdx); 246 + }); 200 247 }); 201 248 202 249 describe('Yjs cell data round-trip integrity', () => {