@@ -11,6 +11,19 @@ jest.mock('sentry/views/seerExplorer/utils', () => ({
1111 usePageReferrer : jest . fn ( ) ,
1212} ) ) ;
1313
14+ // Controlled mock for useTimeout — lets tests trigger the timeout callback directly
15+ // instead of relying on real or fake timers (which conflict with router initialization).
16+ const mockTimeoutStart = jest . fn ( ) ;
17+ const mockTimeoutCancel = jest . fn ( ) ;
18+ let capturedOnTimeout : ( ( ) => void ) | null = null ;
19+
20+ jest . mock ( 'sentry/utils/useTimeout' , ( ) => ( {
21+ useTimeout : ( { onTimeout} : { onTimeout : ( ) => void ; timeMs : number } ) => {
22+ capturedOnTimeout = onTimeout ;
23+ return { start : mockTimeoutStart , cancel : mockTimeoutCancel , end : jest . fn ( ) } ;
24+ } ,
25+ } ) ) ;
26+
1427describe ( 'useSeerExplorer' , ( ) => {
1528 beforeEach ( ( ) => {
1629 MockApiClient . clearMockResponses ( ) ;
@@ -349,4 +362,253 @@ describe('useSeerExplorer', () => {
349362 expect ( result . current . deletedFromIndex ) . toBe ( 0 ) ;
350363 } ) ;
351364 } ) ;
365+
366+ describe ( 'Timeout' , ( ) => {
367+ beforeEach ( ( ) => {
368+ mockTimeoutStart . mockClear ( ) ;
369+ mockTimeoutCancel . mockClear ( ) ;
370+ capturedOnTimeout = null ;
371+ } ) ;
372+
373+ it ( 'isTimedOut is false by default' , ( ) => {
374+ MockApiClient . addMockResponse ( {
375+ url : `/organizations/${ organization . slug } /seer/explorer-chat/` ,
376+ method : 'GET' ,
377+ body : { session : null } ,
378+ } ) ;
379+
380+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
381+
382+ expect ( result . current . isTimedOut ) . toBe ( false ) ;
383+ } ) ;
384+
385+ it ( 'isTimedOut becomes true when the timeout fires during a request' , async ( ) => {
386+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
387+
388+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
389+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'POST' , body : { run_id : 1 } } ) ;
390+ MockApiClient . addMockResponse ( {
391+ url : `${ chatUrl } 1/` ,
392+ method : 'GET' ,
393+ body : {
394+ session : {
395+ blocks : [ ] ,
396+ run_id : 1 ,
397+ status : 'processing' ,
398+ updated_at : '2024-01-01T00:00:00Z' ,
399+ } ,
400+ } ,
401+ } ) ;
402+
403+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
404+
405+ await act ( async ( ) => {
406+ await result . current . sendMessage ( 'Test' ) ;
407+ } ) ;
408+
409+ // Simulate the 7-minute timeout firing
410+ act ( ( ) => {
411+ capturedOnTimeout ?.( ) ;
412+ } ) ;
413+
414+ expect ( result . current . isTimedOut ) . toBe ( true ) ;
415+ expect ( result . current . isPolling ) . toBe ( false ) ;
416+ } ) ;
417+
418+ it ( 'isTimedOut resets to false when a new message is sent after timeout' , async ( ) => {
419+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
420+
421+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
422+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'POST' , body : { run_id : 1 } } ) ;
423+ MockApiClient . addMockResponse ( {
424+ url : `${ chatUrl } 1/` ,
425+ method : 'GET' ,
426+ body : {
427+ session : {
428+ blocks : [ ] ,
429+ run_id : 1 ,
430+ status : 'processing' ,
431+ updated_at : '2024-01-01T00:00:00Z' ,
432+ } ,
433+ } ,
434+ } ) ;
435+
436+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
437+
438+ await act ( async ( ) => {
439+ await result . current . sendMessage ( 'First message' ) ;
440+ } ) ;
441+ act ( ( ) => {
442+ capturedOnTimeout ?.( ) ;
443+ } ) ;
444+ expect ( result . current . isTimedOut ) . toBe ( true ) ;
445+
446+ // Send a new message — isTimedOut should reset.
447+ // After the first send, runId is set to 1, so subsequent sends POST to the run-scoped URL.
448+ MockApiClient . addMockResponse ( {
449+ url : `${ chatUrl } 1/` ,
450+ method : 'POST' ,
451+ body : { run_id : 1 } ,
452+ } ) ;
453+ MockApiClient . addMockResponse ( {
454+ url : `${ chatUrl } 1/` ,
455+ method : 'GET' ,
456+ body : {
457+ session : {
458+ blocks : [ ] ,
459+ run_id : 1 ,
460+ status : 'processing' ,
461+ updated_at : '2024-01-01T00:01:00Z' ,
462+ } ,
463+ } ,
464+ } ) ;
465+
466+ await act ( async ( ) => {
467+ await result . current . sendMessage ( 'Second message' ) ;
468+ } ) ;
469+
470+ expect ( result . current . isTimedOut ) . toBe ( false ) ;
471+ } ) ;
472+
473+ it ( 'timeout timer is cancelled when the response loads successfully' , async ( ) => {
474+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
475+
476+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
477+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'POST' , body : { run_id : 1 } } ) ;
478+ MockApiClient . addMockResponse ( {
479+ url : `${ chatUrl } 1/` ,
480+ method : 'GET' ,
481+ body : {
482+ session : {
483+ blocks : [
484+ {
485+ id : 'a1' ,
486+ message : { role : 'assistant' , content : 'Done' } ,
487+ timestamp : '2024-01-01T00:00:01Z' ,
488+ loading : false ,
489+ } ,
490+ ] ,
491+ run_id : 1 ,
492+ status : 'completed' ,
493+ updated_at : '2024-01-01T00:00:01Z' ,
494+ } ,
495+ } ,
496+ } ) ;
497+
498+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
499+
500+ await act ( async ( ) => {
501+ await result . current . sendMessage ( 'Test' ) ;
502+ } ) ;
503+
504+ // isTimedOut should remain false — the timeout callback was never triggered
505+ expect ( result . current . isTimedOut ) . toBe ( false ) ;
506+ } ) ;
507+
508+ it ( 'polling timeout timer is cancelled when a request errors' , async ( ) => {
509+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
510+
511+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
512+ MockApiClient . addMockResponse ( {
513+ url : chatUrl ,
514+ method : 'POST' ,
515+ statusCode : 500 ,
516+ body : { detail : 'Server error' } ,
517+ } ) ;
518+
519+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
520+
521+ await act ( async ( ) => {
522+ await result . current . sendMessage ( 'Test' ) ;
523+ } ) ;
524+
525+ // cancelPollingTimeout is called via _onRequestError when the API errors
526+ expect ( mockTimeoutCancel ) . toHaveBeenCalled ( ) ;
527+ expect ( result . current . isTimedOut ) . toBe ( false ) ;
528+ } ) ;
529+
530+ it ( 'isTimedOut resets to false when switching to a different run' , async ( ) => {
531+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
532+
533+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
534+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'POST' , body : { run_id : 1 } } ) ;
535+ MockApiClient . addMockResponse ( {
536+ url : `${ chatUrl } 1/` ,
537+ method : 'GET' ,
538+ body : {
539+ session : {
540+ blocks : [ ] ,
541+ run_id : 1 ,
542+ status : 'processing' ,
543+ updated_at : '2024-01-01T00:00:00Z' ,
544+ } ,
545+ } ,
546+ } ) ;
547+ MockApiClient . addMockResponse ( {
548+ url : `${ chatUrl } 999/` ,
549+ method : 'GET' ,
550+ body : {
551+ session : {
552+ blocks : [ ] ,
553+ run_id : 999 ,
554+ status : 'completed' ,
555+ updated_at : '2024-01-01T00:00:00Z' ,
556+ } ,
557+ } ,
558+ } ) ;
559+
560+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
561+
562+ await act ( async ( ) => {
563+ await result . current . sendMessage ( 'Test' ) ;
564+ } ) ;
565+ act ( ( ) => {
566+ capturedOnTimeout ?.( ) ;
567+ } ) ;
568+ expect ( result . current . isTimedOut ) . toBe ( true ) ;
569+
570+ act ( ( ) => {
571+ result . current . switchToRun ( 999 ) ;
572+ } ) ;
573+
574+ expect ( result . current . isTimedOut ) . toBe ( false ) ;
575+ } ) ;
576+
577+ it ( 'isPolling is true while waiting for response and false after timeout fires' , async ( ) => {
578+ const chatUrl = `/organizations/${ organization . slug } /seer/explorer-chat/` ;
579+
580+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'GET' , body : { session : null } } ) ;
581+ MockApiClient . addMockResponse ( { url : chatUrl , method : 'POST' , body : { run_id : 1 } } ) ;
582+ MockApiClient . addMockResponse ( {
583+ url : `${ chatUrl } 1/` ,
584+ method : 'GET' ,
585+ body : {
586+ session : {
587+ blocks : [ ] ,
588+ run_id : 1 ,
589+ status : 'processing' ,
590+ updated_at : '2024-01-01T00:00:00Z' ,
591+ } ,
592+ } ,
593+ } ) ;
594+
595+ const { result} = renderHookWithProviders ( ( ) => useSeerExplorer ( ) , { organization} ) ;
596+
597+ expect ( result . current . isPolling ) . toBe ( false ) ;
598+
599+ await act ( async ( ) => {
600+ await result . current . sendMessage ( 'Test' ) ;
601+ } ) ;
602+
603+ // isPolling is true while the request is in flight (waitingForResponse=true)
604+ expect ( result . current . isPolling ) . toBe ( true ) ;
605+
606+ // Firing the timeout clears waitingForResponse, which stops polling
607+ act ( ( ) => {
608+ capturedOnTimeout ?.( ) ;
609+ } ) ;
610+
611+ expect ( result . current . isPolling ) . toBe ( false ) ;
612+ } ) ;
613+ } ) ;
352614} ) ;
0 commit comments