Skip to content
Merged
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
13 changes: 13 additions & 0 deletions app/src/pages/onboarding/pages/ContextPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react';
import { useNavigate } from 'react-router-dom';

import { trackEvent } from '../../../services/analytics';
Expand All @@ -15,8 +16,20 @@ const ContextPage = () => {
// 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' },
});
});
}}
onBack={() => navigate('/onboarding/skills')}
Expand Down
77 changes: 77 additions & 0 deletions app/src/pages/onboarding/pages/__tests__/ContextPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* 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 { 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() }));

vi.mock('@sentry/react', () => ({ captureException: hoisted.captureException }));

// `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()}>
Continue to chat
</button>
),
}));

function renderContextPage(completeAndExit: OnboardingContextValue['completeAndExit']) {
const value: OnboardingContextValue = {
draft: { connectedSources: ['composio:gmail'] },
setDraft: vi.fn(),
completeAndExit,
};
return renderWithProviders(
<OnboardingContext.Provider value={value}>
<ContextPage />
</OnboardingContext.Provider>
);
}

describe('ContextPage — onboarding-complete Sentry capture (#2081)', () => {
beforeEach(() => {
hoisted.captureException.mockReset();
});

it('captures a completeAndExit rejection to Sentry with the documented tags', 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(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();
});
});
8 changes: 8 additions & 0 deletions app/src/pages/onboarding/steps/ContextGatheringStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* External calls still go through core (auth, proxy, billing). Only the
* stage-by-stage orchestration lives in the renderer.
*/
import * as Sentry from '@sentry/react';
import { useEffect, useRef, useState } from 'react';

import { useT } from '../../../lib/i18n/I18nContext';
Expand Down Expand Up @@ -372,6 +373,13 @@ const ContextGatheringStep = ({
const t = setTimeout(() => {
void Promise.resolve(onNext()).catch(e => {
console.warn('[onboarding:context] auto-advance failed', e);
// Mirrors the manual click capture in ContextPage so the auto-
// advance failure mode is not a Sentry blind spot (#2081). The
// step tag distinguishes it from `continue-to-chat` clicks in
// the dashboard.
Sentry.captureException(e, {
tags: { flow: 'onboarding-complete', step: 'auto-advance' },
});
setHasError(true);
});
}, 800);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,21 @@ const getCoreRpcUrl = vi.hoisted(() => vi.fn(async () => 'http://127.0.0.1:7788/
const testCoreRpcConnection = vi.hoisted(() =>
vi.fn(async () => ({ ok: true, status: 200 }) as Response)
);
const captureException = vi.hoisted(() => vi.fn());
vi.mock('../../../../services/coreRpcClient', () => ({
callCoreRpc,
getCoreRpcUrl,
testCoreRpcConnection,
}));
vi.mock('@sentry/react', () => ({ captureException }));

describe('ContextGatheringStep', () => {
beforeEach(() => {
callCoreRpc.mockReset();
getCoreRpcUrl.mockClear();
testCoreRpcConnection.mockClear();
testCoreRpcConnection.mockResolvedValue({ ok: true, status: 200 } as Response);
captureException.mockReset();
});

it('no-Gmail branch: auto-navigates without any RPC', async () => {
Expand Down Expand Up @@ -289,6 +292,27 @@ describe('ContextGatheringStep', () => {
// just verify the friendly message is shown
});

it('captures auto-advance onNext rejection to Sentry with the documented tags (#2081)', async () => {
vi.useFakeTimers();
const failure = new Error('app_state_snapshot timed out');
const onNext = vi.fn().mockRejectedValue(failure);

renderWithProviders(<ContextGatheringStep connectedSources={['notion']} onNext={onNext} />);

// No-Gmail branch finishes synchronously, then auto-advance fires after 800ms.
await act(async () => {
await vi.advanceTimersByTimeAsync(900);
});

expect(onNext).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(captureException).toHaveBeenCalledTimes(1));
const [thrown, ctx] = captureException.mock.calls[0];
expect(thrown).toBe(failure);
expect(ctx).toEqual({ tags: { flow: 'onboarding-complete', step: 'auto-advance' } });

vi.useRealTimers();
});

// --------------------------------------------------------------------------
// #2156 — slow-but-alive snapshot / save_profile path
// --------------------------------------------------------------------------
Expand Down
Loading