Skip to content

Commit cc89ff3

Browse files
committed
jest test
1 parent 3558941 commit cc89ff3

File tree

2 files changed

+341
-0
lines changed

2 files changed

+341
-0
lines changed

static/app/views/seerExplorer/explorerPanel.spec.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ import {
1313
import * as useSeerExplorerModule from './hooks/useSeerExplorer';
1414
import {ExplorerPanel} from './explorerPanel';
1515

16+
function mockUseSeerExplorer(
17+
overrides: Partial<ReturnType<typeof useSeerExplorerModule.useSeerExplorer>>
18+
) {
19+
return jest.spyOn(useSeerExplorerModule, 'useSeerExplorer').mockReturnValue({
20+
runId: null,
21+
sessionData: null,
22+
sendMessage: jest.fn(),
23+
deleteFromIndex: jest.fn(),
24+
startNewSession: jest.fn(),
25+
isPolling: false,
26+
isError: false,
27+
isTimedOut: false,
28+
deletedFromIndex: null,
29+
interruptRun: jest.fn(),
30+
interruptRequested: false,
31+
wasJustInterrupted: false,
32+
switchToRun: jest.fn(),
33+
respondToUserInput: jest.fn(),
34+
createPR: jest.fn(),
35+
overrideCtxEngEnable: true,
36+
setOverrideCtxEngEnable: jest.fn(),
37+
...overrides,
38+
});
39+
}
40+
1641
// Mock createPortal to render content directly
1742
jest.mock('react-dom', () => ({
1843
...jest.requireActual('react-dom'),
@@ -206,6 +231,7 @@ describe('ExplorerPanel', () => {
206231
startNewSession: jest.fn(),
207232
isPolling: false,
208233
isError: true, // isError
234+
isTimedOut: false,
209235
deletedFromIndex: null,
210236
interruptRun: jest.fn(),
211237
interruptRequested: false,
@@ -267,6 +293,7 @@ describe('ExplorerPanel', () => {
267293
startNewSession: jest.fn(),
268294
isPolling: false,
269295
isError: false,
296+
isTimedOut: false,
270297
deletedFromIndex: null,
271298
interruptRun: jest.fn(),
272299
interruptRequested: false,
@@ -624,4 +651,56 @@ describe('ExplorerPanel', () => {
624651
expect(await screen.findByTestId('seer-explorer-input')).toBeInTheDocument();
625652
});
626653
});
654+
655+
describe('Timeout UI', () => {
656+
afterEach(() => {
657+
jest.restoreAllMocks();
658+
});
659+
660+
it('shows timeout message in empty state when isTimedOut is true', async () => {
661+
mockUseSeerExplorer({isTimedOut: true});
662+
663+
renderWithPanelContext(<ExplorerPanel />, true, {organization});
664+
665+
expect(
666+
await screen.findByText('The request timed out. Please try again.')
667+
).toBeInTheDocument();
668+
expect(
669+
screen.queryByText(/Ask Seer anything about your application./)
670+
).not.toBeInTheDocument();
671+
});
672+
673+
it('shows timeout placeholder on the input when isTimedOut is true', async () => {
674+
mockUseSeerExplorer({isTimedOut: true});
675+
676+
renderWithPanelContext(<ExplorerPanel />, true, {organization});
677+
678+
const textarea = await screen.findByTestId('seer-explorer-input');
679+
expect(textarea).toHaveAttribute(
680+
'placeholder',
681+
'The request timed out. Please try again.'
682+
);
683+
});
684+
685+
it('input is still enabled when timed out so user can retry', async () => {
686+
mockUseSeerExplorer({isTimedOut: true});
687+
688+
renderWithPanelContext(<ExplorerPanel />, true, {organization});
689+
690+
const textarea = await screen.findByTestId('seer-explorer-input');
691+
expect(textarea).toBeEnabled();
692+
});
693+
694+
it('timeout placeholder takes precedence over interrupted state', async () => {
695+
mockUseSeerExplorer({isTimedOut: true, wasJustInterrupted: true});
696+
697+
renderWithPanelContext(<ExplorerPanel />, true, {organization});
698+
699+
const textarea = await screen.findByTestId('seer-explorer-input');
700+
expect(textarea).toHaveAttribute(
701+
'placeholder',
702+
'The request timed out. Please try again.'
703+
);
704+
});
705+
});
627706
});

static/app/views/seerExplorer/hooks/useSeerExplorer.spec.tsx

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
1427
describe('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

Comments
 (0)