diff --git a/e2e/multi-tab.spec.ts b/e2e/multi-tab.spec.ts index daf94cc..26ba6e1 100644 --- a/e2e/multi-tab.spec.ts +++ b/e2e/multi-tab.spec.ts @@ -305,11 +305,76 @@ test.describe('TabMesh — multi-tab harness', () => { // Plus a delivery URL endpoint in the test fixture. }); - test.fixme('Elected-leader failover within 50ms (#28-#31)', async () => { - // Force fallback by deleting `window.SharedWorker` before mesh.start(). - // Then open 3 tabs, identify the leader (term + tabId), close it, and - // verify another tab claims leadership inside the Web Locks SLA. - // Requires either a debug system event from the leader or a - // `mesh.getStatus()` field exposing the current term/leaderTabId. + test('elected-leader failover when the leader tab closes (#28-#31)', async ({ context }) => { + // Force fallback mode via `?hub=elected` (deletes window.SharedWorker + // before mesh.start). Open 3 tabs, identify the leader, close it, and + // verify a different tab takes over with a higher term. + const a = await newPlaygroundTab(context, { hub: 'elected' }); + const b = await newPlaygroundTab(context, { hub: 'elected' }); + const c = await newPlaygroundTab(context, { hub: 'elected' }); + + type Status = { + role: 'hub' | 'follower' | null; + tabId: string; + leaderTabId: string | null; + term: number; + }; + const readStatus = (page: Page): Promise => + page.evaluate(() => { + const m = (globalThis as unknown as { __tabmesh: { getStatus(): Status } }).__tabmesh; + return m.getStatus(); + }); + const allReady = async (): Promise => { + const statuses = await Promise.all([a, b, c].map(readStatus)); + return statuses; + }; + + // Wait for exactly one leader to be elected across the three tabs. + await expect + .poll( + async () => { + const statuses = await allReady(); + return statuses.filter((s) => s.role === 'hub').length; + }, + { timeout: 10_000 } + ) + .toBe(1); + + const initial = await allReady(); + const leader = initial.find((s) => s.role === 'hub'); + expect(leader).toBeDefined(); + if (!leader) throw new Error('unreachable'); + const leaderTabId = leader.tabId; + + const leaderPage = [a, b, c].find((page, i) => initial[i] && initial[i]?.tabId === leaderTabId); + if (!leaderPage) throw new Error('could not match leader page'); + + await leaderPage.close(); + const survivors = [a, b, c].filter((p) => !p.isClosed()); + expect(survivors.length).toBe(2); + + // The remaining tabs must elect a new leader. Web Locks failover is + // sub-50ms in spec; BC heartbeat fallback is ~1.5s; IDB is up to 5s. + // Headless Chromium supports Web Locks, but we keep a generous bound. + // + // Note: in Web Locks mode the per-tab `term` counter doesn't carry + // across tabs (each tab tracks its own term-of-this-leadership), so + // the meaningful assertion is "a different tab is now the leader." + await expect + .poll( + async () => { + const updated = await Promise.all(survivors.map(readStatus)); + const newLeader = updated.find((s) => s.role === 'hub'); + if (!newLeader) return null; + if (newLeader.tabId === leaderTabId) return null; + return newLeader.tabId; + }, + { timeout: 10_000 } + ) + .not.toBeNull(); + + for (const page of survivors) { + await page.close(); + } }); }); diff --git a/packages/core/src/TabMesh.ts b/packages/core/src/TabMesh.ts index 5dbcabf..c99d083 100644 --- a/packages/core/src/TabMesh.ts +++ b/packages/core/src/TabMesh.ts @@ -375,14 +375,31 @@ export class TabMesh { * Get the current status of the mesh. */ getStatus(): TabMeshStatus { + let role: TabMeshStatus['role'] = null; + let leaderTabId: string | null = null; + let term = 0; + if (this.hubMode === 'shared-worker') { + // In shared-worker mode every tab is a follower of the worker. There + // is no "leader" concept — the worker isn't a tab. + role = 'follower'; + } else if (this.hub instanceof ElectedLeaderHub) { + const snap = this.hub.getElectionSnapshot(); + if (snap) { + role = snap.isLeader ? 'hub' : 'follower'; + leaderTabId = snap.leaderTabId; + term = snap.term; + } + } return { started: this.started, hubMode: this.hubMode, hubConnected: this.hub?.connected ?? false, - role: this.hubMode === 'shared-worker' ? 'follower' : null, + role, transportState: this.transportState, tabId: this.tabId, degraded: this.degraded, + leaderTabId, + term, }; } diff --git a/packages/core/src/hub/ElectedLeaderHub.ts b/packages/core/src/hub/ElectedLeaderHub.ts index 2f7dbdc..81e3641 100644 --- a/packages/core/src/hub/ElectedLeaderHub.ts +++ b/packages/core/src/hub/ElectedLeaderHub.ts @@ -65,6 +65,19 @@ export class ElectedLeaderHub implements Hub { return this._connected; } + /** + * Snapshot of the current leader-election state. Returns `null` if the + * hub hasn't started electing yet. Used by TabMesh.getStatus(). + */ + getElectionSnapshot(): { isLeader: boolean; leaderTabId: string | null; term: number } | null { + if (!this.leader) return null; + return { + isLeader: this.leader.leader, + leaderTabId: this.leader.leaderTabId, + term: this.leader.term, + }; + } + async connect(tabId: string): Promise { this.tabId = tabId; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4e52901..45dc8b8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -393,6 +393,18 @@ export interface TabMeshStatus { tabId: string; /** Whether persistence is degraded (in-memory fallback). */ degraded: boolean; + /** + * Current elected-leader's tab id, when running in elected-leader mode. + * Null in shared-worker mode (no leader concept) or before the first + * leader is elected. + */ + leaderTabId?: string | null; + /** + * Current election term, when running in elected-leader mode. Increments + * with each leadership transition. Useful for detecting failover and + * resolving split-brain. + */ + term?: number; } // --------------------------------------------------------------------------- diff --git a/packages/playground/src/mesh.ts b/packages/playground/src/mesh.ts index 7b958eb..6812baa 100644 --- a/packages/playground/src/mesh.ts +++ b/packages/playground/src/mesh.ts @@ -34,3 +34,9 @@ function numberFromParam(name: string): number | undefined { const parsed = Number(raw); return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; } + +// Expose the mesh instance on `window` for the e2e harness. The playground +// is a demo app — there's no privacy boundary to protect — and the +// alternative (rendering every diagnostic field on screen) bloats the UI +// for tests no human would read. +(globalThis as unknown as { __tabmesh: typeof mesh }).__tabmesh = mesh;