@@ -15,16 +15,24 @@ function makeMessage(id: string): Message {
1515 } ;
1616}
1717
18+ interface QueueOpts {
19+ flushIntervalMs ?: number ;
20+ flushSize ?: number ;
21+ onFlush ?: ( ok : boolean , count : number ) => void ;
22+ staleFilter ?: ( msg : Message ) => boolean ;
23+ }
24+
1825function createQueue (
1926 transport : Transport ,
20- opts : { flushIntervalMs ?: number ; flushSize ?: number } = { } ,
27+ opts : QueueOpts = { } ,
2128) {
2229 return new MessageQueue (
2330 transport ,
2431 'https://api.immutable.com/v1/audience/messages' ,
2532 'pk_imx_test' ,
2633 opts . flushIntervalMs ?? 5_000 ,
2734 opts . flushSize ?? 20 ,
35+ { onFlush : opts . onFlush , staleFilter : opts . staleFilter } ,
2836 ) ;
2937}
3038
@@ -110,6 +118,17 @@ describe('MessageQueue', () => {
110118 expect ( queue . length ) . toBe ( 1 ) ;
111119 } ) ;
112120
121+ it ( 'filters stale messages on restore' , ( ) => {
122+ storage . setItem ( 'queue' , [ makeMessage ( 'stale' ) , makeMessage ( 'fresh' ) ] ) ;
123+
124+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
125+ const queue = createQueue ( { send } , {
126+ staleFilter : ( m ) => m . messageId === 'fresh' ,
127+ } ) ;
128+
129+ expect ( queue . length ) . toBe ( 1 ) ;
130+ } ) ;
131+
113132 it ( 'does not flush concurrently' , async ( ) => {
114133 let resolveFirst : ( ) => void ;
115134 const firstCall = new Promise < boolean > ( ( r ) => { resolveFirst = ( ) => r ( true ) ; } ) ;
@@ -144,7 +163,6 @@ describe('MessageQueue', () => {
144163 it ( 'handles messages enqueued during flush' , async ( ) => {
145164 let queue : ReturnType < typeof createQueue > ;
146165 const send = jest . fn ( ) . mockImplementation ( async ( ) => {
147- // Simulate a message arriving during the network request
148166 queue . enqueue ( makeMessage ( 'late' ) ) ;
149167 return true ;
150168 } ) ;
@@ -154,28 +172,52 @@ describe('MessageQueue', () => {
154172
155173 await queue . flush ( ) ;
156174
157- // The original message was sent, but the late one should remain
158175 expect ( queue . length ) . toBe ( 1 ) ;
159176 } ) ;
160- } ) ;
161177
162- describe ( 'page-unload flush' , ( ) => {
163- let sendBeaconSpy : jest . SpyInstance ;
178+ it ( 'calls onFlush callback' , async ( ) => {
179+ const onFlush = jest . fn ( ) ;
180+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
181+ const queue = createQueue ( { send } , { onFlush } ) ;
164182
165- beforeEach ( ( ) => {
166- sendBeaconSpy = jest . fn ( ) . mockReturnValue ( true ) ;
167- Object . defineProperty ( navigator , 'sendBeacon' , {
168- value : sendBeaconSpy ,
169- writable : true ,
170- configurable : true ,
171- } ) ;
183+ queue . enqueue ( makeMessage ( '1' ) ) ;
184+ await queue . flush ( ) ;
185+
186+ expect ( onFlush ) . toHaveBeenCalledWith ( true , 1 ) ;
172187 } ) ;
173188
174- afterEach ( ( ) => {
175- sendBeaconSpy . mockRestore ?.( ) ;
189+ it ( 'purges messages matching a predicate' , ( ) => {
190+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
191+ const queue = createQueue ( { send } ) ;
192+
193+ queue . enqueue ( makeMessage ( '1' ) ) ;
194+ queue . enqueue ( { ...makeMessage ( '2' ) , type : 'identify' } as any ) ;
195+ queue . enqueue ( makeMessage ( '3' ) ) ;
196+
197+ queue . purge ( ( m ) => m . type === 'identify' ) ;
198+ expect ( queue . length ) . toBe ( 2 ) ;
176199 } ) ;
177200
178- it ( 'flushes via sendBeacon on visibilitychange to hidden' , ( ) => {
201+ it ( 'transforms messages in place' , async ( ) => {
202+ const send = jest . fn ( ) . mockResolvedValue ( true ) ;
203+ const queue = createQueue ( { send } ) ;
204+
205+ queue . enqueue ( { ...makeMessage ( '1' ) , userId : 'should-strip' } as any ) ;
206+
207+ queue . transform ( ( m ) => {
208+ const cleaned = { ...m } ;
209+ delete ( cleaned as any ) . userId ;
210+ return cleaned ;
211+ } ) ;
212+
213+ await queue . flush ( ) ;
214+ const msg = send . mock . calls [ 0 ] [ 2 ] . messages [ 0 ] ;
215+ expect ( ( msg as any ) . userId ) . toBeUndefined ( ) ;
216+ } ) ;
217+ } ) ;
218+
219+ describe ( 'page-unload flush (keepalive)' , ( ) => {
220+ it ( 'flushes via keepalive fetch on visibilitychange to hidden' , ( ) => {
179221 const send = jest . fn ( ) . mockResolvedValue ( true ) ;
180222 const queue = createQueue ( { send } ) ;
181223 queue . start ( ) ;
@@ -189,10 +231,11 @@ describe('page-unload flush', () => {
189231 } ) ;
190232 document . dispatchEvent ( new Event ( 'visibilitychange' ) ) ;
191233
192- expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
193- expect ( sendBeaconSpy ) . toHaveBeenCalledWith (
234+ expect ( send ) . toHaveBeenCalledWith (
194235 'https://api.immutable.com/v1/audience/messages' ,
195- expect . any ( Blob ) ,
236+ 'pk_imx_test' ,
237+ expect . objectContaining ( { messages : expect . any ( Array ) } ) ,
238+ { keepalive : true } ,
196239 ) ;
197240 expect ( queue . length ) . toBe ( 0 ) ;
198241
@@ -204,28 +247,33 @@ describe('page-unload flush', () => {
204247 } ) ;
205248 } ) ;
206249
207- it ( 'flushes via sendBeacon on pagehide' , ( ) => {
250+ it ( 'flushes via keepalive fetch on pagehide' , ( ) => {
208251 const send = jest . fn ( ) . mockResolvedValue ( true ) ;
209252 const queue = createQueue ( { send } ) ;
210253 queue . start ( ) ;
211254
212255 queue . enqueue ( makeMessage ( '1' ) ) ;
213256 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
214257
215- expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
258+ expect ( send ) . toHaveBeenCalledWith (
259+ 'https://api.immutable.com/v1/audience/messages' ,
260+ 'pk_imx_test' ,
261+ expect . objectContaining ( { messages : expect . any ( Array ) } ) ,
262+ { keepalive : true } ,
263+ ) ;
216264 expect ( queue . length ) . toBe ( 0 ) ;
217265
218266 queue . stop ( ) ;
219267 } ) ;
220268
221- it ( 'does not fire beacon when queue is empty' , ( ) => {
269+ it ( 'does not fire unload flush when queue is empty' , ( ) => {
222270 const send = jest . fn ( ) . mockResolvedValue ( true ) ;
223271 const queue = createQueue ( { send } ) ;
224272 queue . start ( ) ;
225273
226274 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
227275
228- expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
276+ expect ( send ) . not . toHaveBeenCalled ( ) ;
229277
230278 queue . stop ( ) ;
231279 } ) ;
@@ -239,7 +287,7 @@ describe('page-unload flush', () => {
239287 queue . enqueue ( makeMessage ( '1' ) ) ;
240288 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
241289
242- expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
290+ expect ( send ) . not . toHaveBeenCalled ( ) ;
243291 } ) ;
244292
245293 it ( 'destroy stops the queue and flushes remaining messages' , ( ) => {
@@ -251,52 +299,21 @@ describe('page-unload flush', () => {
251299 queue . enqueue ( makeMessage ( '2' ) ) ;
252300 queue . destroy ( ) ;
253301
254- expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
302+ expect ( send ) . toHaveBeenCalledWith (
303+ expect . any ( String ) ,
304+ expect . any ( String ) ,
305+ expect . objectContaining ( { messages : expect . any ( Array ) } ) ,
306+ { keepalive : true } ,
307+ ) ;
255308 expect ( queue . length ) . toBe ( 0 ) ;
256309
257310 // Listeners removed — no double flush
258311 queue . enqueue ( makeMessage ( '3' ) ) ;
259312 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
260- expect ( sendBeaconSpy ) . toHaveBeenCalledTimes ( 1 ) ;
261- } ) ;
262-
263- it ( 'falls back to async flush if sendBeacon returns false' , async ( ) => {
264- sendBeaconSpy . mockReturnValue ( false ) ;
265- const send = jest . fn ( ) . mockResolvedValue ( true ) ;
266- const queue = createQueue ( { send } ) ;
267- queue . start ( ) ;
268-
269- queue . enqueue ( makeMessage ( '1' ) ) ;
270- window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
271-
272- // sendBeacon failed, so async flush should have been triggered
273- await Promise . resolve ( ) ;
274- expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
275-
276- queue . stop ( ) ;
277- } ) ;
278-
279- it ( 'falls back to async flush if sendBeacon is unavailable' , async ( ) => {
280- Object . defineProperty ( navigator , 'sendBeacon' , {
281- value : undefined ,
282- writable : true ,
283- configurable : true ,
284- } ) ;
285-
286- const send = jest . fn ( ) . mockResolvedValue ( true ) ;
287- const queue = createQueue ( { send } ) ;
288- queue . start ( ) ;
289-
290- queue . enqueue ( makeMessage ( '1' ) ) ;
291- window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
292-
293- await Promise . resolve ( ) ;
294313 expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
295-
296- queue . stop ( ) ;
297314 } ) ;
298315
299- it ( 'skips beacon if an async flush is already in flight' , async ( ) => {
316+ it ( 'skips unload flush if an async flush is already in flight' , async ( ) => {
300317 let resolveFlush : ( ) => void ;
301318 const flushPromise = new Promise < boolean > ( ( r ) => { resolveFlush = ( ) => r ( true ) ; } ) ;
302319 const send = jest . fn ( ) . mockReturnValueOnce ( flushPromise ) ;
@@ -308,9 +325,10 @@ describe('page-unload flush', () => {
308325 // Start an async flush (sets flushing = true)
309326 const pending = queue . flush ( ) ;
310327
311- // pagehide fires while async flush is in flight — beacon should be skipped
328+ // pagehide fires while async flush is in flight — unload flush should be skipped
312329 window . dispatchEvent ( new Event ( 'pagehide' ) ) ;
313- expect ( sendBeaconSpy ) . not . toHaveBeenCalled ( ) ;
330+ // Only 1 call (the async flush), no keepalive call
331+ expect ( send ) . toHaveBeenCalledTimes ( 1 ) ;
314332
315333 resolveFlush ! ( ) ;
316334 await pending ;
0 commit comments