11import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest' ;
2- import { CollabClient , type CollabSnapshot } from '../src/collab/collab-client.js' ;
2+ import {
3+ CollabClient ,
4+ type CollabPresenceMember ,
5+ type CollabSnapshot ,
6+ } from '../src/collab/collab-client.js' ;
37import { workspaceContextFixture } from './helpers/workspace-context' ;
48
59const TEAM_CONTEXT = workspaceContextFixture ( {
@@ -15,12 +19,16 @@ interface RecordedCall {
1519}
1620
1721interface FakeFetchOptions {
18- present ?: Array < { memberId : string ; name ?: string } > ;
22+ present ?: CollabPresenceMember [ ] ;
1923 publishedVersion ?: number | null ;
2024 syncState ?: string | null ;
2125 failPath ?: string ;
2226}
2327
28+ const PRESENCE_TEST_NOW = Date . parse ( '2026-08-05T00:00:00.000Z' ) ;
29+ const presenceTime = ( offsetMs = 0 ) =>
30+ new Date ( PRESENCE_TEST_NOW + offsetMs ) . toISOString ( ) ;
31+
2432function makeFetch ( options : FakeFetchOptions = { } ) {
2533 const calls : RecordedCall [ ] = [ ] ;
2634 const state = {
@@ -54,6 +62,7 @@ function makeFetch(options: FakeFetchOptions = {}) {
5462
5563beforeEach ( ( ) => {
5664 vi . useFakeTimers ( ) ;
65+ vi . setSystemTime ( PRESENCE_TEST_NOW ) ;
5766} ) ;
5867
5968afterEach ( ( ) => {
@@ -627,11 +636,11 @@ describe('CollabClient', () => {
627636 client . stop ( ) ;
628637 } ) ;
629638
630- it ( 'retains a peer for one heartbeat window when one self-bearing roster omits it' , async ( ) => {
639+ it ( 'retains a peer for the upstream lease window when self-bearing rosters briefly omit it' , async ( ) => {
631640 const { fetchImpl, state } = makeFetch ( {
632641 present : [
633- { memberId : 'viewer' , name : 'Viewer' } ,
634- { memberId : 'peer' , name : 'Peer' } ,
642+ { memberId : 'viewer' , name : 'Viewer' , heartbeatAt : presenceTime ( ) } ,
643+ { memberId : 'peer' , name : 'Peer' , heartbeatAt : presenceTime ( ) } ,
635644 ] ,
636645 } ) ;
637646 const client = new CollabClient ( {
@@ -646,20 +655,154 @@ describe('CollabClient', () => {
646655 await vi . advanceTimersByTimeAsync ( 0 ) ;
647656 expect ( client . getSnapshot ( ) . present ) . toHaveLength ( 2 ) ;
648657
649- state . present = [ { memberId : 'viewer' , name : 'Viewer' } ] ;
658+ state . present = [ {
659+ memberId : 'viewer' ,
660+ name : 'Viewer' ,
661+ heartbeatAt : presenceTime ( 10_000 ) ,
662+ } ] ;
650663 await vi . advanceTimersByTimeAsync ( 10_000 ) ;
651- expect ( client . getSnapshot ( ) . present ) . toEqual ( [
652- { memberId : 'viewer' , name : 'Viewer' } ,
653- { memberId : 'peer' , name : 'Peer' } ,
654- ] ) ;
664+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
665+ . toEqual ( [ 'viewer' , 'peer' ] ) ;
666+
667+ // The Vela presence lease is authoritative for 30 seconds from the last
668+ // roster that actually contained the peer. A delayed heartbeat can make
669+ // the peer absent from the 10s and 20s reads without making that last-good
670+ // evidence stale. Dropping it after one interval makes the non-owner side
671+ // flicker while the owner's local self witness hides the same upstream gap.
672+ await vi . advanceTimersByTimeAsync ( 19_999 ) ;
673+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
674+ . toEqual ( [ 'viewer' , 'peer' ] ) ;
675+
676+ // At the exact upstream TTL boundary the peer is no longer retained.
677+ await vi . advanceTimersByTimeAsync ( 1 ) ;
678+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
679+ . toEqual ( [ 'viewer' ] ) ;
680+
681+ client . stop ( ) ;
682+ } ) ;
683+
684+ it ( 'never flashes a known peer when it returns before the upstream lease window ends' , async ( ) => {
685+ const { fetchImpl, state } = makeFetch ( {
686+ present : [
687+ { memberId : 'viewer' , name : 'Viewer' , heartbeatAt : presenceTime ( ) } ,
688+ { memberId : 'owner' , name : 'Owner' , heartbeatAt : presenceTime ( ) } ,
689+ ] ,
690+ } ) ;
691+ const snapshots : CollabPresenceMember [ ] [ ] = [ ] ;
692+ const client = new CollabClient ( {
693+ projectId : 'p-owner-transient-gap' ,
694+ member : { memberId : 'viewer' , name : 'Viewer' } ,
695+ fetch : fetchImpl ,
696+ heartbeatMs : 10_000 ,
697+ onUpdate : ( snapshot ) => snapshots . push ( snapshot . present ) ,
698+ } ) ;
655699
656- await vi . advanceTimersByTimeAsync ( 9_999 ) ;
700+ client . start ( ) ;
701+ await vi . advanceTimersByTimeAsync ( 0 ) ;
702+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
703+ . toEqual ( [ 'viewer' , 'owner' ] ) ;
704+ snapshots . length = 0 ;
705+ state . present = [ {
706+ memberId : 'viewer' ,
707+ name : 'Viewer' ,
708+ heartbeatAt : presenceTime ( 10_000 ) ,
709+ } ] ;
710+ await vi . advanceTimersByTimeAsync ( 20_000 ) ;
711+ state . present = [
712+ { memberId : 'viewer' , name : 'Viewer' , heartbeatAt : presenceTime ( 30_000 ) } ,
713+ { memberId : 'owner' , name : 'Owner' , heartbeatAt : presenceTime ( 30_000 ) } ,
714+ ] ;
715+ await vi . advanceTimersByTimeAsync ( 10_000 ) ;
716+
717+ expect ( snapshots . every ( ( present ) =>
718+ present . some ( ( { memberId } ) => memberId === 'owner' ) ) ) . toBe ( true ) ;
657719 expect ( client . getSnapshot ( ) . present ) . toHaveLength ( 2 ) ;
720+ client . stop ( ) ;
721+ } ) ;
722+
723+ it ( 'does not extend a nearly expired backend lease from the local observation time' , async ( ) => {
724+ vi . setSystemTime ( PRESENCE_TEST_NOW + 29_000 ) ;
725+ const { fetchImpl, state } = makeFetch ( {
726+ present : [
727+ { memberId : 'viewer' , heartbeatAt : presenceTime ( 29_000 ) } ,
728+ { memberId : 'owner' , heartbeatAt : presenceTime ( ) } ,
729+ ] ,
730+ } ) ;
731+ const client = new CollabClient ( {
732+ projectId : 'p-nearly-expired-owner' ,
733+ member : { memberId : 'viewer' } ,
734+ fetch : fetchImpl ,
735+ heartbeatMs : 1_000 ,
736+ } ) ;
737+
738+ client . start ( ) ;
739+ await vi . advanceTimersByTimeAsync ( 0 ) ;
740+ state . present = [ {
741+ memberId : 'viewer' ,
742+ heartbeatAt : presenceTime ( 30_000 ) ,
743+ } ] ;
744+ await vi . advanceTimersByTimeAsync ( 999 ) ;
745+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
746+ . toEqual ( [ 'viewer' , 'owner' ] ) ;
747+
658748 await vi . advanceTimersByTimeAsync ( 1 ) ;
749+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
750+ . toEqual ( [ 'viewer' ] ) ;
751+ client . stop ( ) ;
752+ } ) ;
753+
754+ it . each ( [
755+ [ 'missing' , undefined ] ,
756+ [ 'invalid' , 'not-a-date' ] ,
757+ ] ) ( 'retains a peer with %s heartbeatAt for only one fallback heartbeat window' , async ( _label , heartbeatAt ) => {
758+ const { fetchImpl, state } = makeFetch ( {
759+ present : [
760+ { memberId : 'viewer' } ,
761+ { memberId : 'legacy-peer' , heartbeatAt } ,
762+ ] ,
763+ } ) ;
764+ const client = new CollabClient ( {
765+ projectId : 'p-legacy-presence' ,
766+ member : { memberId : 'viewer' } ,
767+ fetch : fetchImpl ,
768+ heartbeatMs : 10_000 ,
769+ } ) ;
770+
771+ client . start ( ) ;
772+ await vi . advanceTimersByTimeAsync ( 0 ) ;
773+ state . present = [ { memberId : 'viewer' } ] ;
774+ await vi . advanceTimersByTimeAsync ( 19_999 ) ;
775+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
776+ . toEqual ( [ 'viewer' , 'legacy-peer' ] ) ;
777+
778+ await vi . advanceTimersByTimeAsync ( 1 ) ;
779+ expect ( client . getSnapshot ( ) . present . map ( ( { memberId } ) => memberId ) )
780+ . toEqual ( [ 'viewer' ] ) ;
781+ client . stop ( ) ;
782+ } ) ;
783+
784+ it ( 'applies an event-driven fresh roster exactly so explicit leaves stay immediate' , async ( ) => {
785+ const { fetchImpl, state } = makeFetch ( {
786+ present : [
787+ { memberId : 'viewer' , name : 'Viewer' } ,
788+ { memberId : 'peer' , name : 'Peer' } ,
789+ ] ,
790+ } ) ;
791+ const client = new CollabClient ( {
792+ projectId : 'p-explicit-leave' ,
793+ member : { memberId : 'viewer' , name : 'Viewer' } ,
794+ fetch : fetchImpl ,
795+ heartbeatMs : 10_000 ,
796+ } ) ;
797+
798+ client . start ( ) ;
799+ await vi . advanceTimersByTimeAsync ( 0 ) ;
800+ state . present = [ { memberId : 'viewer' , name : 'Viewer' } ] ;
801+ await client . refreshPresence ( ) ;
802+
659803 expect ( client . getSnapshot ( ) . present ) . toEqual ( [
660804 { memberId : 'viewer' , name : 'Viewer' } ,
661805 ] ) ;
662-
663806 client . stop ( ) ;
664807 } ) ;
665808
0 commit comments