diff --git a/app/src/pages/onboarding/pages/ContextPage.tsx b/app/src/pages/onboarding/pages/ContextPage.tsx index e341e2831a..14d972b500 100644 --- a/app/src/pages/onboarding/pages/ContextPage.tsx +++ b/app/src/pages/onboarding/pages/ContextPage.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { trackEvent } from '../../../services/analytics'; @@ -13,24 +12,10 @@ const ContextPage = () => { { trackEvent('onboarding_step_complete', { step_name: 'context' }); - // The completeAndExit chain awaits four core RPCs - // (app_state_update_local_state → app_state_snapshot → - // config_set_onboarding_completed → app_state_snapshot). When any - // rejects, the user sees a silent dead "Continue to chat" button - // (#2081). The `.catch` below kept the rejection out of Sentry's - // global handlers because a handled rejection never fires - // `unhandledrejection`. Forward to Sentry explicitly so the - // dashboard can show whether #2179 (snapshot timeout) closed the - // symptom in practice. - void completeAndExit().catch(error => { - console.error('[onboarding:context-page] completeAndExit failed', error); - Sentry.captureException(error, { - tags: { flow: 'onboarding-complete', step: 'continue-to-chat' }, - }); - }); + return completeAndExit(); }} onBack={() => navigate('/onboarding/skills')} /> diff --git a/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx b/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx index e543fa0c8e..f74f7895fe 100644 --- a/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx +++ b/app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx @@ -1,31 +1,21 @@ -/** - * Verifies that `ContextPage` forwards a `completeAndExit` rejection into - * Sentry with the documented tags (#2081). The handled `.catch` would - * otherwise keep the failure out of `Sentry.globalHandlersIntegration`, so - * an explicit capture is the only way the dashboard sees it. - */ -import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '../../../../test/test-utils'; import { OnboardingContext, type OnboardingContextValue } from '../../OnboardingContext'; import ContextPage from '../ContextPage'; -const hoisted = vi.hoisted(() => ({ captureException: vi.fn() })); +const hoisted = vi.hoisted(() => ({ lastOnNextResult: undefined as unknown, trackEvent: vi.fn() })); -vi.mock('@sentry/react', () => ({ captureException: hoisted.captureException })); +vi.mock('../../../../services/analytics', () => ({ trackEvent: hoisted.trackEvent })); -// `trackEvent` writes through to analytics → react-ga4 / Sentry. Stub it so -// the rejection-capture assertion isn't entangled with consent / GA wiring. -vi.mock('../../../../services/analytics', () => ({ trackEvent: vi.fn() })); - -// The page renders `ContextGatheringStep`, which auto-starts a heavy pipeline -// on mount when `connectedSources` includes a Composio Gmail entry. Stub it -// to a button that simply invokes `onNext` so the failure path is reachable -// without simulating the full pipeline. vi.mock('../../steps/ContextGatheringStep', () => ({ default: ({ onNext }: { onNext: () => void | Promise }) => ( - ), @@ -44,34 +34,23 @@ function renderContextPage(completeAndExit: OnboardingContextValue['completeAndE ); } -describe('ContextPage — onboarding-complete Sentry capture (#2081)', () => { +describe('ContextPage', () => { beforeEach(() => { - hoisted.captureException.mockReset(); + hoisted.lastOnNextResult = undefined; + hoisted.trackEvent.mockReset(); }); - it('captures a completeAndExit rejection to Sentry with the documented tags', async () => { + it('returns the completeAndExit promise so the step can show click feedback', async () => { const failure = new Error('app_state_snapshot timed out'); const completeAndExit = vi.fn().mockRejectedValue(failure); renderContextPage(completeAndExit); fireEvent.click(screen.getByTestId('continue-to-chat')); - await waitFor(() => expect(hoisted.captureException).toHaveBeenCalledTimes(1)); + expect(hoisted.trackEvent).toHaveBeenCalledWith('onboarding_step_complete', { + step_name: 'context', + }); expect(completeAndExit).toHaveBeenCalledTimes(1); - - const [thrown, ctx] = hoisted.captureException.mock.calls[0]; - expect(thrown).toBe(failure); - expect(ctx).toEqual({ tags: { flow: 'onboarding-complete', step: 'continue-to-chat' } }); - }); - - it('does not capture anything when completeAndExit resolves', async () => { - const completeAndExit = vi.fn().mockResolvedValue(undefined); - - renderContextPage(completeAndExit); - fireEvent.click(screen.getByTestId('continue-to-chat')); - - // Let the resolved promise settle. - await waitFor(() => expect(completeAndExit).toHaveBeenCalledTimes(1)); - expect(hoisted.captureException).not.toHaveBeenCalled(); + await expect(hoisted.lastOnNextResult).rejects.toBe(failure); }); }); diff --git a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx index 043538eaf0..923ceb246c 100644 --- a/app/src/pages/onboarding/steps/ContextGatheringStep.tsx +++ b/app/src/pages/onboarding/steps/ContextGatheringStep.tsx @@ -190,6 +190,7 @@ const ContextGatheringStep = ({ ); const [finished, setFinished] = useState(false); const [hasError, setHasError] = useState(false); + const [isCompleting, setIsCompleting] = useState(false); // Staged "still working" mode kicks in after STILL_WORKING_THRESHOLD_MS so // a slow-but-alive first launch no longer looks like a stall (#2156). const [stillWorking, setStillWorking] = useState(false); @@ -283,13 +284,24 @@ const ContextGatheringStep = ({ } const continueToChat = () => { + if (isCompleting) return; backgroundClickedRef.current = true; console.debug('[onboarding:context] user continued before pipeline completion', { finished, hasError, stages: stageStatusesRef.current, }); - void onNext(); + setIsCompleting(true); + void Promise.resolve(onNext()) + .catch(e => { + console.warn('[onboarding:context] continue-to-chat failed', e); + Sentry.captureException(e, { + tags: { flow: 'onboarding-complete', step: 'continue-to-chat' }, + }); + setHasError(true); + setFinished(true); + }) + .finally(() => setIsCompleting(false)); }; // Auto-start pipeline on mount @@ -401,6 +413,8 @@ const ContextGatheringStep = ({ @@ -477,6 +491,8 @@ const ContextGatheringStep = ({ )} diff --git a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx index 073bbfa1ae..967d7cf0bf 100644 --- a/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx +++ b/app/src/pages/onboarding/steps/__tests__/ContextGatheringStep.test.tsx @@ -170,6 +170,34 @@ describe('ContextGatheringStep', () => { }); }); + it('shows an error card and captures Sentry when manual continue fails', async () => { + let resolveGmail!: (v: unknown) => void; + callCoreRpc.mockImplementation( + () => + new Promise(res => { + resolveGmail = res; + }) + ); + const failure = new Error('app_state_snapshot timed out'); + const onNext = vi.fn().mockRejectedValue(failure); + + renderWithProviders( + + ); + + fireEvent.click(screen.getByRole('button', { name: /continue to chat/i })); + + await waitFor(() => expect(captureException).toHaveBeenCalledTimes(1)); + const [thrown, ctx] = captureException.mock.calls[0]; + expect(thrown).toBe(failure); + expect(ctx).toEqual({ tags: { flow: 'onboarding-complete', step: 'continue-to-chat' } }); + expect(screen.getByText(/your chat is ready/i)).toBeInTheDocument(); + + await act(async () => { + resolveGmail({ successful: true, data: { messages: [] } }); + }); + }); + it('hides the manual continue button if the pipeline finishes quickly', async () => { callCoreRpc.mockResolvedValue({ successful: true, data: { messages: [] } });