11import { describe , expect , it , mock } from 'bun:test'
2- import type { ConversationPanelSnapshot } from '@browseros/shared/schemas/conversation-panels'
2+ import type { ConversationPanelAssignments } from '@browseros/shared/schemas/conversation-panels'
33import {
44 ConversationPanelBroker ,
55 type ConversationPanelBrokerDeps ,
6+ type ConversationPanelBrokerErrorContext ,
67} from './conversationPanelBroker'
8+ import type { ConversationPanelViews } from './conversationPanelStorage'
79
810describe ( 'ConversationPanelBroker' , ( ) => {
911 it ( 'opens every touched tab and retains its conversation mapping' , async ( ) => {
1012 const fixture = createFixture ( )
1113
12- await fixture . broker . accept (
13- snapshot ( [
14- tab ( 10 , 'conversation-1' , 'run-1' , 'running' ) ,
15- tab ( 11 , 'conversation-1' , 'run-1' , 'running' ) ,
14+ await fixture . broker . reconcile (
15+ assignments ( [
16+ assignment ( 10 , 'conversation-1' , 'run-1' , 'running' ) ,
17+ assignment ( 11 , 'conversation-1' , 'run-1' , 'running' ) ,
1618 ] ) ,
1719 )
1820
@@ -33,14 +35,14 @@ describe('ConversationPanelBroker', () => {
3335
3436 it ( 'does not let an older run overwrite a tab claimed by a newer run' , async ( ) => {
3537 const fixture = createFixture ( )
36- await fixture . broker . accept (
37- snapshot ( [ tab ( 20 , 'older' , 'old-run' , 'running' ) ] ) ,
38+ await fixture . broker . reconcile (
39+ assignments ( [ assignment ( 20 , 'older' , 'old-run' , 'running' ) ] ) ,
3840 )
39- const newer = snapshot ( [ tab ( 20 , 'newer' , 'new-run' , 'running' ) ] )
40- await fixture . broker . accept ( newer )
41+ const newer = assignments ( [ assignment ( 20 , 'newer' , 'new-run' , 'running' ) ] )
42+ await fixture . broker . reconcile ( newer )
4143
4244 // A stale run finishing cannot appear in the server's canonical mapping.
43- await fixture . broker . accept ( newer )
45+ await fixture . broker . reconcile ( newer )
4446
4547 expect ( fixture . views [ '20' ] ) . toMatchObject ( {
4648 conversationId : 'newer' ,
@@ -53,18 +55,20 @@ describe('ConversationPanelBroker', () => {
5355 } )
5456 } )
5557
56- it ( 'treats a reconnect snapshot as authoritative and reopens running tabs' , async ( ) => {
57- const fixture = createFixture ( {
58- '99' : tab ( 99 , 'stale' , 'stale-run' , 'running' ) ,
59- } )
60- const event : ConversationPanelSnapshot = {
61- tabs : [
62- tab ( 30 , 'active' , 'active-run' , 'running' ) ,
63- tab ( 31 , 'done' , 'done-run' , 'completed' ) ,
64- ] ,
65- }
58+ it ( 'treats reconnect assignments as authoritative' , async ( ) => {
59+ const fixture = createFixture ( )
60+ await fixture . broker . reconcile (
61+ assignments ( [ assignment ( 99 , 'stale' , 'stale-run' , 'running' ) ] ) ,
62+ )
63+ fixture . opened . length = 0
64+ fixture . glow . length = 0
6665
67- await fixture . broker . accept ( event )
66+ await fixture . broker . reconcile (
67+ assignments ( [
68+ assignment ( 30 , 'active' , 'active-run' , 'running' ) ,
69+ assignment ( 31 , 'done' , 'done-run' , 'completed' ) ,
70+ ] ) ,
71+ )
6872
6973 expect ( fixture . views [ '99' ] ) . toBeUndefined ( )
7074 expect ( fixture . opened ) . toEqual ( [ { tabId : 30 , windowId : 1 } ] )
@@ -78,17 +82,17 @@ describe('ConversationPanelBroker', () => {
7882
7983 it ( 'deactivates a finished run and shows first-run confetti once' , async ( ) => {
8084 const fixture = createFixture ( )
81- await fixture . broker . accept (
82- snapshot ( [
83- tab ( 40 , 'conversation-4' , 'run-4' , 'running' ) ,
84- tab ( 41 , 'conversation-4' , 'run-4' , 'running' ) ,
85+ await fixture . broker . reconcile (
86+ assignments ( [
87+ assignment ( 40 , 'conversation-4' , 'run-4' , 'running' ) ,
88+ assignment ( 41 , 'conversation-4' , 'run-4' , 'running' ) ,
8589 ] ) ,
8690 )
8791
88- await fixture . broker . accept (
89- snapshot ( [
90- tab ( 40 , 'conversation-4' , 'run-4' , 'completed' ) ,
91- tab ( 41 , 'conversation-4' , 'run-4' , 'completed' ) ,
92+ await fixture . broker . reconcile (
93+ assignments ( [
94+ assignment ( 40 , 'conversation-4' , 'run-4' , 'completed' ) ,
95+ assignment ( 41 , 'conversation-4' , 'run-4' , 'completed' ) ,
9296 ] ) ,
9397 )
9498
@@ -108,29 +112,127 @@ describe('ConversationPanelBroker', () => {
108112 ] )
109113 expect ( fixture . markConfettiShown ) . toHaveBeenCalledTimes ( 1 )
110114 } )
115+
116+ it ( 'retries the complete handoff after a transient storage failure' , async ( ) => {
117+ const fixture = createFixture ( {
118+ writeViews : async ( _views , call ) => {
119+ if ( call === 1 ) throw new Error ( 'session storage not ready' )
120+ } ,
121+ } )
122+ const current = assignments ( [
123+ assignment ( 50 , 'conversation-5' , 'run-5' , 'running' ) ,
124+ ] )
125+
126+ await expect ( fixture . broker . reconcile ( current ) ) . rejects . toThrow (
127+ 'session storage not ready' ,
128+ )
129+ await fixture . broker . reconcile ( current )
130+
131+ expect ( fixture . opened ) . toEqual ( [ { tabId : 50 , windowId : 1 } ] )
132+ expect ( fixture . views [ '50' ] ) . toMatchObject ( { runId : 'run-5' } )
133+ } )
134+
135+ it ( 'retries initialization after a transient storage-read failure' , async ( ) => {
136+ const fixture = createFixture ( {
137+ readViews : async ( call ) => {
138+ if ( call === 1 ) throw new Error ( 'session storage not ready' )
139+ return { }
140+ } ,
141+ } )
142+ const current = assignments ( [
143+ assignment ( 55 , 'conversation-5' , 'run-5' , 'running' ) ,
144+ ] )
145+
146+ await expect ( fixture . broker . reconcile ( current ) ) . rejects . toThrow (
147+ 'session storage not ready' ,
148+ )
149+ await fixture . broker . reconcile ( current )
150+
151+ expect ( fixture . opened ) . toEqual ( [ { tabId : 55 , windowId : 1 } ] )
152+ } )
153+
154+ it ( 'reasserts open panels on a heartbeat without restarting their glow' , async ( ) => {
155+ const fixture = createFixture ( )
156+ const current = assignments ( [
157+ assignment ( 60 , 'conversation-6' , 'run-6' , 'running' ) ,
158+ ] )
159+
160+ await fixture . broker . reconcile ( current )
161+ await fixture . broker . reconcile ( current )
162+
163+ expect ( fixture . opened ) . toEqual ( [
164+ { tabId : 60 , windowId : 1 } ,
165+ { tabId : 60 , windowId : 1 } ,
166+ ] )
167+ expect ( fixture . glow ) . toEqual ( [
168+ { tabId : 60 , isActive : true , conversationId : 'conversation-6' } ,
169+ ] )
170+ } )
171+
172+ it ( 'heals a transient panel-open failure on the next heartbeat' , async ( ) => {
173+ const fixture = createFixture ( {
174+ openPanel : async ( _target , call ) => {
175+ if ( call === 1 ) throw new Error ( 'side panel temporarily unavailable' )
176+ } ,
177+ } )
178+ const current = assignments ( [
179+ assignment ( 70 , 'conversation-7' , 'run-7' , 'running' ) ,
180+ ] )
181+
182+ await fixture . broker . reconcile ( current )
183+ await fixture . broker . reconcile ( current )
184+
185+ expect ( fixture . opened ) . toEqual ( [ { tabId : 70 , windowId : 1 } ] )
186+ expect ( fixture . errors ) . toEqual ( [
187+ expect . objectContaining ( {
188+ context : { phase : 'open-panel' , tabId : 70 } ,
189+ } ) ,
190+ ] )
191+ } )
111192} )
112193
113- function createFixture (
114- initialViews : Record < string , ReturnType < typeof tab > > = { } ,
115- ) {
116- const views = { ...initialViews }
194+ interface FixtureOptions {
195+ readViews ?( call : number ) : Promise < ConversationPanelViews >
196+ writeViews ?( views : ConversationPanelViews , call : number ) : Promise < void >
197+ openPanel ?(
198+ target : { tabId : number ; windowId : number } ,
199+ call : number ,
200+ ) : Promise < void >
201+ }
202+
203+ function createFixture ( options : FixtureOptions = { } ) {
204+ const views : ConversationPanelViews = { }
117205 const opened : Array < { tabId : number ; windowId : number } > = [ ]
118206 const glow : Array < {
119207 tabId : number
120208 conversationId : string
121209 isActive : boolean
122210 showConfetti ?: boolean
123211 } > = [ ]
212+ const errors : Array < {
213+ error : unknown
214+ context : ConversationPanelBrokerErrorContext
215+ } > = [ ]
124216 const markConfettiShown = mock ( async ( ) => { } )
217+ let readViewsCalls = 0
218+ let writeViewsCalls = 0
219+ let openPanelCalls = 0
125220 const deps : ConversationPanelBrokerDeps = {
126221 resolveServerUrl : async ( ) => 'http://127.0.0.1:9000' ,
127222 fetch : mock ( async ( ) => new Response ( ) ) ,
128223 getTab : async ( tabId ) => ( { id : tabId , windowId : 1 } ) ,
129224 openPanel : async ( target ) => {
225+ openPanelCalls += 1
226+ await options . openPanel ?.( target , openPanelCalls )
130227 opened . push ( target )
131228 } ,
132- readViews : async ( ) => ( { ...views } ) ,
229+ readViews : async ( ) => {
230+ readViewsCalls += 1
231+ return ( await options . readViews ?.( readViewsCalls ) ) ?? { ...views }
232+ } ,
133233 writeViews : async ( next ) => {
234+ writeViewsCalls += 1
235+ await options . writeViews ?.( next , writeViewsCalls )
134236 for ( const key of Object . keys ( views ) ) delete views [ key ]
135237 Object . assign ( views , next )
136238 } ,
@@ -139,17 +241,19 @@ function createFixture(
139241 } ,
140242 hasShownConfetti : async ( ) => false ,
141243 markConfettiShown,
244+ reportError : ( error , context ) => errors . push ( { error, context } ) ,
142245 }
143246 return {
144247 broker : new ConversationPanelBroker ( deps ) ,
248+ errors,
145249 glow,
146250 markConfettiShown,
147251 opened,
148252 views,
149253 }
150254}
151255
152- function tab (
256+ function assignment (
153257 tabId : number ,
154258 conversationId : string ,
155259 runId : string ,
@@ -158,8 +262,8 @@ function tab(
158262 return { tabId, conversationId, runId, status }
159263}
160264
161- function snapshot (
162- tabs : ConversationPanelSnapshot [ 'tabs '] ,
163- ) : ConversationPanelSnapshot {
164- return { tabs }
265+ function assignments (
266+ values : ConversationPanelAssignments [ 'assignments '] ,
267+ ) : ConversationPanelAssignments {
268+ return { assignments : values }
165269}
0 commit comments