@@ -305,11 +305,76 @@ test.describe('TabMesh — multi-tab harness', () => {
305305 // Plus a delivery URL endpoint in the test fixture.
306306 } ) ;
307307
308- test . fixme ( 'Elected-leader failover within 50ms (#28-#31)' , async ( ) => {
309- // Force fallback by deleting `window.SharedWorker` before mesh.start().
310- // Then open 3 tabs, identify the leader (term + tabId), close it, and
311- // verify another tab claims leadership inside the Web Locks SLA.
312- // Requires either a debug system event from the leader or a
313- // `mesh.getStatus()` field exposing the current term/leaderTabId.
308+ test ( 'elected-leader failover when the leader tab closes (#28-#31)' , async ( { context } ) => {
309+ // Force fallback mode via `?hub=elected` (deletes window.SharedWorker
310+ // before mesh.start). Open 3 tabs, identify the leader, close it, and
311+ // verify a different tab takes over with a higher term.
312+ const a = await newPlaygroundTab ( context , { hub : 'elected' } ) ;
313+ const b = await newPlaygroundTab ( context , { hub : 'elected' } ) ;
314+ const c = await newPlaygroundTab ( context , { hub : 'elected' } ) ;
315+
316+ type Status = {
317+ role : 'hub' | 'follower' | null ;
318+ tabId : string ;
319+ leaderTabId : string | null ;
320+ term : number ;
321+ } ;
322+ const readStatus = ( page : Page ) : Promise < Status > =>
323+ page . evaluate ( ( ) => {
324+ const m = ( globalThis as unknown as { __tabmesh : { getStatus ( ) : Status } } ) . __tabmesh ;
325+ return m . getStatus ( ) ;
326+ } ) ;
327+ const allReady = async ( ) : Promise < Status [ ] > => {
328+ const statuses = await Promise . all ( [ a , b , c ] . map ( readStatus ) ) ;
329+ return statuses ;
330+ } ;
331+
332+ // Wait for exactly one leader to be elected across the three tabs.
333+ await expect
334+ . poll (
335+ async ( ) => {
336+ const statuses = await allReady ( ) ;
337+ return statuses . filter ( ( s ) => s . role === 'hub' ) . length ;
338+ } ,
339+ { timeout : 10_000 }
340+ )
341+ . toBe ( 1 ) ;
342+
343+ const initial = await allReady ( ) ;
344+ const leader = initial . find ( ( s ) => s . role === 'hub' ) ;
345+ expect ( leader ) . toBeDefined ( ) ;
346+ if ( ! leader ) throw new Error ( 'unreachable' ) ;
347+ const leaderTabId = leader . tabId ;
348+
349+ const leaderPage = [ a , b , c ] . find ( ( page , i ) => initial [ i ] && initial [ i ] ?. tabId === leaderTabId ) ;
350+ if ( ! leaderPage ) throw new Error ( 'could not match leader page' ) ;
351+
352+ await leaderPage . close ( ) ;
353+ const survivors = [ a , b , c ] . filter ( ( p ) => ! p . isClosed ( ) ) ;
354+ expect ( survivors . length ) . toBe ( 2 ) ;
355+
356+ // The remaining tabs must elect a new leader. Web Locks failover is
357+ // sub-50ms in spec; BC heartbeat fallback is ~1.5s; IDB is up to 5s.
358+ // Headless Chromium supports Web Locks, but we keep a generous bound.
359+ //
360+ // Note: in Web Locks mode the per-tab `term` counter doesn't carry
361+ // across tabs (each tab tracks its own term-of-this-leadership), so
362+ // the meaningful assertion is "a different tab is now the leader."
363+ await expect
364+ . poll (
365+ async ( ) => {
366+ const updated = await Promise . all ( survivors . map ( readStatus ) ) ;
367+ const newLeader = updated . find ( ( s ) => s . role === 'hub' ) ;
368+ if ( ! newLeader ) return null ;
369+ if ( newLeader . tabId === leaderTabId ) return null ;
370+ return newLeader . tabId ;
371+ } ,
372+ { timeout : 10_000 }
373+ )
374+ . not . toBeNull ( ) ;
375+
376+ for ( const page of survivors ) {
377+ await page . close ( ) ;
378+ }
314379 } ) ;
315380} ) ;
0 commit comments