@@ -2,13 +2,19 @@ import { describe, expect, test } from 'bun:test';
22import { Doc as YDoc , XmlElement } from 'yjs' ;
33import {
44 DEFAULT_BOOTSTRAP_SETTLING_MS ,
5+ DEFAULT_BOOTSTRAP_JITTER_MS ,
56 detectRoomState ,
67 resolveBootstrapDecision ,
78 writeBootstrapMarker ,
89 claimBootstrap ,
10+ detectBootstrapRace ,
911 type BootstrapMarker ,
1012} from '../bootstrap' ;
1113
14+ // ---------------------------------------------------------------------------
15+ // detectRoomState
16+ // ---------------------------------------------------------------------------
17+
1218describe ( 'detectRoomState' , ( ) => {
1319 test ( 'returns "empty" for a fresh ydoc' , ( ) => {
1420 const ydoc = new YDoc ( ) ;
@@ -54,6 +60,10 @@ describe('detectRoomState', () => {
5460 } ) ;
5561} ) ;
5662
63+ // ---------------------------------------------------------------------------
64+ // resolveBootstrapDecision
65+ // ---------------------------------------------------------------------------
66+
5767describe ( 'resolveBootstrapDecision' , ( ) => {
5868 test ( 'populated room always joins' , ( ) => {
5969 expect ( resolveBootstrapDecision ( 'populated' , 'seedFromDoc' , true ) ) . toEqual ( { action : 'join' } ) ;
@@ -62,26 +72,30 @@ describe('resolveBootstrapDecision', () => {
6272 expect ( resolveBootstrapDecision ( 'populated' , 'error' , true ) ) . toEqual ( { action : 'join' } ) ;
6373 } ) ;
6474
65- test ( 'empty + seedFromDoc + hasDoc → seed from doc' , ( ) => {
75+ test ( 'empty + seedFromDoc + hasDoc -> seed from doc' , ( ) => {
6676 expect ( resolveBootstrapDecision ( 'empty' , 'seedFromDoc' , true ) ) . toEqual ( { action : 'seed' , source : 'doc' } ) ;
6777 } ) ;
6878
69- test ( 'empty + seedFromDoc + no doc → seed from blank' , ( ) => {
79+ test ( 'empty + seedFromDoc + no doc -> seed from blank' , ( ) => {
7080 expect ( resolveBootstrapDecision ( 'empty' , 'seedFromDoc' , false ) ) . toEqual ( { action : 'seed' , source : 'blank' } ) ;
7181 } ) ;
7282
73- test ( 'empty + blank → seed from blank regardless of hasDoc' , ( ) => {
83+ test ( 'empty + blank -> seed from blank regardless of hasDoc' , ( ) => {
7484 expect ( resolveBootstrapDecision ( 'empty' , 'blank' , true ) ) . toEqual ( { action : 'seed' , source : 'blank' } ) ;
7585 expect ( resolveBootstrapDecision ( 'empty' , 'blank' , false ) ) . toEqual ( { action : 'seed' , source : 'blank' } ) ;
7686 } ) ;
7787
78- test ( 'empty + error → error' , ( ) => {
88+ test ( 'empty + error -> error' , ( ) => {
7989 const result = resolveBootstrapDecision ( 'empty' , 'error' , true ) ;
8090 expect ( result . action ) . toBe ( 'error' ) ;
8191 expect ( ( result as { reason : string } ) . reason ) . toContain ( 'onMissing' ) ;
8292 } ) ;
8393} ) ;
8494
95+ // ---------------------------------------------------------------------------
96+ // writeBootstrapMarker
97+ // ---------------------------------------------------------------------------
98+
8599describe ( 'writeBootstrapMarker' , ( ) => {
86100 test ( 'writes marker to meta map with correct shape' , ( ) => {
87101 const ydoc = new YDoc ( ) ;
@@ -102,30 +116,34 @@ describe('writeBootstrapMarker', () => {
102116 } ) ;
103117} ) ;
104118
119+ // ---------------------------------------------------------------------------
120+ // claimBootstrap
121+ // ---------------------------------------------------------------------------
122+
105123describe ( 'claimBootstrap' , ( ) => {
106- test ( 'returns true when this client owns the marker' , async ( ) => {
124+ test ( 'returns granted when this client owns the marker' , async ( ) => {
107125 const ydoc = new YDoc ( ) ;
108- const result = await claimBootstrap ( ydoc , 0 ) ;
109- expect ( result ) . toBe ( true ) ;
126+ const result = await claimBootstrap ( ydoc , 0 , 0 ) ;
127+ expect ( result . granted ) . toBe ( true ) ;
110128
111129 const marker = ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) as BootstrapMarker ;
112130 expect ( marker . clientId ) . toBe ( ydoc . clientID ) ;
113131 } ) ;
114132
115133 test ( 'claim marker has source "pending"' , async ( ) => {
116134 const ydoc = new YDoc ( ) ;
117- await claimBootstrap ( ydoc , 0 ) ;
135+ await claimBootstrap ( ydoc , 0 , 0 ) ;
118136
119137 const marker = ydoc . getMap ( 'meta' ) . get ( 'bootstrap' ) as BootstrapMarker ;
120138 expect ( marker . source ) . toBe ( 'pending' ) ;
121139 } ) ;
122140
123- test ( 'returns false when another client overwrites the marker during settling' , async ( ) => {
141+ test ( 'returns denied with competitor info when another client overwrites during settling' , async ( ) => {
124142 const ydoc = new YDoc ( ) ;
125143 const otherClientId = ydoc . clientID + 1 ;
126144 const metaMap = ydoc . getMap ( 'meta' ) ;
127145
128- const promise = claimBootstrap ( ydoc , 10 ) ;
146+ const promise = claimBootstrap ( ydoc , 20 , 0 ) ;
129147
130148 // Overwrite with the other client's marker during the settling window
131149 setTimeout ( ( ) => {
@@ -138,11 +156,67 @@ describe('claimBootstrap', () => {
138156 } , 2 ) ;
139157
140158 const result = await promise ;
141- expect ( result ) . toBe ( false ) ;
159+ expect ( result . granted ) . toBe ( false ) ;
160+ if ( ! result . granted ) {
161+ expect ( result . competitor . observedOtherClientId ) . toBe ( otherClientId ) ;
162+ expect ( result . competitor . observedSource ) . toBe ( 'pending' ) ;
163+ expect ( typeof result . competitor . observedAt ) . toBe ( 'string' ) ;
164+ }
165+ } ) ;
166+
167+ test ( 'observe detects late-arriving marker after sleep ends' , async ( ) => {
168+ // Simulates network latency: the competing marker arrives just before
169+ // the final read, but the observe handler catches it reactively.
170+ const ydoc = new YDoc ( ) ;
171+ const otherClientId = ydoc . clientID + 1 ;
172+ const metaMap = ydoc . getMap ( 'meta' ) ;
173+
174+ const promise = claimBootstrap ( ydoc , 5 , 0 ) ;
175+
176+ // Overwrite at ~4ms — very close to when the sleep ends
177+ setTimeout ( ( ) => {
178+ metaMap . set ( 'bootstrap' , {
179+ version : 1 ,
180+ clientId : otherClientId ,
181+ seededAt : new Date ( ) . toISOString ( ) ,
182+ source : 'pending' ,
183+ } ) ;
184+ } , 4 ) ;
185+
186+ const result = await promise ;
187+ expect ( result . granted ) . toBe ( false ) ;
188+ } ) ;
189+
190+ test ( 'returns denied gracefully when marker is removed during settling' , async ( ) => {
191+ const ydoc = new YDoc ( ) ;
192+ const metaMap = ydoc . getMap ( 'meta' ) ;
193+
194+ const promise = claimBootstrap ( ydoc , 20 , 0 ) ;
195+
196+ // Another process deletes the bootstrap key during settling
197+ setTimeout ( ( ) => {
198+ metaMap . delete ( 'bootstrap' ) ;
199+ } , 2 ) ;
200+
201+ const result = await promise ;
202+ expect ( result . granted ) . toBe ( false ) ;
203+ if ( ! result . granted ) {
204+ expect ( result . competitor . observedOtherClientId ) . toBe ( 0 ) ;
205+ expect ( result . competitor . observedSource ) . toBe ( 'unknown' ) ;
206+ }
207+ } ) ;
208+
209+ test ( 'jitter=0 disables random delay' , async ( ) => {
210+ const ydoc = new YDoc ( ) ;
211+ const before = Date . now ( ) ;
212+ await claimBootstrap ( ydoc , 0 , 0 ) ;
213+ const elapsed = Date . now ( ) - before ;
214+ // With jitter=0 and settling=0, should complete almost instantly
215+ expect ( elapsed ) . toBeLessThan ( 50 ) ;
142216 } ) ;
143217
144218 test ( 'stale pending marker does not block subsequent bootstrap detection' , async ( ) => {
145- // Simulates Finding 1 : claimer crashes after writing pending marker
219+ // Simulates: claimer crashes after writing pending marker
146220 const ydoc = new YDoc ( ) ;
147221 ydoc . getMap ( 'meta' ) . set ( 'bootstrap' , {
148222 version : 1 ,
@@ -160,7 +234,7 @@ describe('claimBootstrap', () => {
160234 } ) ;
161235
162236 test ( 'concurrent claimers: second claimer re-detects and joins after first seeds' , async ( ) => {
163- // Simulates the full claim→ re-detect→ join path for a race loser
237+ // Simulates the full claim -> re-detect -> join path for a race loser
164238 const ydoc = new YDoc ( ) ;
165239 const otherClientId = ydoc . clientID + 1 ;
166240 const metaMap = ydoc . getMap ( 'meta' ) ;
@@ -180,12 +254,16 @@ describe('claimBootstrap', () => {
180254 } ) ;
181255} ) ;
182256
257+ // ---------------------------------------------------------------------------
258+ // claim loser always yields
259+ // ---------------------------------------------------------------------------
260+
183261describe ( 'claim loser always yields' , ( ) => {
184262 test ( 'loser yields even when winner marker is still pending (room looks empty)' , ( ) => {
185263 // After a failed claim, the loser sees the room with only a pending
186264 // marker (the winner hasn't finalized yet). detectRoomState returns
187265 // 'empty' but the loser must NOT re-seed — they must yield.
188- // This tests the contract that document.ts enforces: claim loser → join.
266+ // This tests the contract that document.ts enforces: claim loser -> join.
189267 const ydoc = new YDoc ( ) ;
190268 ydoc . getMap ( 'meta' ) . set ( 'bootstrap' , {
191269 version : 1 ,
@@ -206,8 +284,73 @@ describe('claim loser always yields', () => {
206284 } ) ;
207285} ) ;
208286
287+ // ---------------------------------------------------------------------------
288+ // detectBootstrapRace
289+ // ---------------------------------------------------------------------------
290+
291+ describe ( 'detectBootstrapRace' , ( ) => {
292+ test ( 'returns raceSuspected: false when no competing marker arrives' , async ( ) => {
293+ const ydoc = new YDoc ( ) ;
294+ writeBootstrapMarker ( ydoc , 'doc' ) ;
295+
296+ const result = await detectBootstrapRace ( ydoc , 10 ) ;
297+ expect ( result . raceSuspected ) . toBe ( false ) ;
298+ } ) ;
299+
300+ test ( 'returns raceSuspected: true with competitor info when another finalized marker arrives' , async ( ) => {
301+ const ydoc = new YDoc ( ) ;
302+ const otherClientId = ydoc . clientID + 1 ;
303+ writeBootstrapMarker ( ydoc , 'doc' ) ;
304+
305+ const promise = detectBootstrapRace ( ydoc , 20 ) ;
306+
307+ // Another client's finalized marker arrives during observation
308+ setTimeout ( ( ) => {
309+ ydoc . getMap ( 'meta' ) . set ( 'bootstrap' , {
310+ version : 1 ,
311+ clientId : otherClientId ,
312+ seededAt : new Date ( ) . toISOString ( ) ,
313+ source : 'doc' ,
314+ } ) ;
315+ } , 5 ) ;
316+
317+ const result = await promise ;
318+ expect ( result . raceSuspected ) . toBe ( true ) ;
319+ if ( result . raceSuspected ) {
320+ expect ( result . competitor . observedOtherClientId ) . toBe ( otherClientId ) ;
321+ expect ( result . competitor . observedSource ) . toBe ( 'doc' ) ;
322+ expect ( typeof result . competitor . observedAt ) . toBe ( 'string' ) ;
323+ }
324+ } ) ;
325+
326+ test ( 'ignores changes to non-bootstrap meta keys' , async ( ) => {
327+ const ydoc = new YDoc ( ) ;
328+ writeBootstrapMarker ( ydoc , 'doc' ) ;
329+
330+ const promise = detectBootstrapRace ( ydoc , 20 ) ;
331+
332+ // Unrelated meta key changes should not trigger false positive
333+ setTimeout ( ( ) => {
334+ ydoc . getMap ( 'meta' ) . set ( 'docx' , 'some-content' ) ;
335+ } , 5 ) ;
336+
337+ const result = await promise ;
338+ expect ( result . raceSuspected ) . toBe ( false ) ;
339+ } ) ;
340+ } ) ;
341+
342+ // ---------------------------------------------------------------------------
343+ // Constants
344+ // ---------------------------------------------------------------------------
345+
209346describe ( 'DEFAULT_BOOTSTRAP_SETTLING_MS' , ( ) => {
210347 test ( 'is a positive number' , ( ) => {
211348 expect ( DEFAULT_BOOTSTRAP_SETTLING_MS ) . toBeGreaterThan ( 0 ) ;
212349 } ) ;
213350} ) ;
351+
352+ describe ( 'DEFAULT_BOOTSTRAP_JITTER_MS' , ( ) => {
353+ test ( 'is a positive number' , ( ) => {
354+ expect ( DEFAULT_BOOTSTRAP_JITTER_MS ) . toBeGreaterThan ( 0 ) ;
355+ } ) ;
356+ } ) ;
0 commit comments