Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions app/src/pages/onboarding/pages/ContextPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as Sentry from '@sentry/react';
import { useNavigate } from 'react-router-dom';

import { trackEvent } from '../../../services/analytics';
Expand All @@ -13,24 +12,10 @@ const ContextPage = () => {
<ContextGatheringStep
connectedSources={draft.connectedSources}
// Chat-provider step is disabled for now, so context-gathering is
// the final step when it runs finish onboarding directly.
// the final step when it runs - finish onboarding directly.
onNext={() => {
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')}
/>
Expand Down
53 changes: 16 additions & 37 deletions app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> }) => (
<button data-testid="continue-to-chat" onClick={() => onNext()}>
<button
data-testid="continue-to-chat"
onClick={() => {
hoisted.lastOnNextResult = onNext();
}}>
Continue to chat
</button>
),
Expand All @@ -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);
});
});
18 changes: 17 additions & 1 deletion app/src/pages/onboarding/steps/ContextGatheringStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -401,6 +413,8 @@ const ContextGatheringStep = ({
<OnboardingNextButton
label={t('onboarding.contextGathering.continueToChat')}
onClick={continueToChat}
loading={isCompleting}
loadingLabel={t('common.loading')}
/>
</div>
</div>
Expand Down Expand Up @@ -477,6 +491,8 @@ const ContextGatheringStep = ({
<OnboardingNextButton
label={t('onboarding.contextGathering.continueToChat')}
onClick={continueToChat}
loading={isCompleting}
loadingLabel={t('common.loading')}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ContextGatheringStep connectedSources={['composio:gmail']} onNext={onNext} />
);

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: [] } });

Expand Down
Loading