Skip to content

Commit 361e429

Browse files
AXON-1694: rovo dev error boundary (#1459)
* rovo dev error boundary * expand href for custom buton in dialog message * add report render error msg type * report redner error msg type * wrap error boundary in rovo dev view * start new session msg * fix: remove delay for reset error state * ut * keep same func signature * fix: ut * fix: structure error log
1 parent 126eb23 commit 361e429

File tree

6 files changed

+433
-4
lines changed

6 files changed

+433
-4
lines changed

src/rovo-dev/rovoDevWebviewProvider.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,10 +515,33 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro
515515
await commands.executeCommand(RovodevCommands.OpenRovoDevLogFile);
516516
break;
517517

518+
case RovoDevViewResponseType.StartNewSession:
519+
await this.executeNewSession();
520+
break;
521+
518522
case RovoDevViewResponseType.MessageRendered:
519523
this._chatProvider.signalMessageRendered(e.promptId);
520524
break;
521525

526+
case RovoDevViewResponseType.ReportRenderError:
527+
const renderError = new Error(`Render Error: ${e.errorMessage}`);
528+
renderError.name = e.errorType;
529+
// Build detailed error context
530+
const errorDetails: string[] = [];
531+
if (e.errorStack) {
532+
errorDetails.push(`Error Stack:\n${e.errorStack}`);
533+
}
534+
if (e.componentStack) {
535+
errorDetails.push(`Component Stack:\n${e.componentStack}`);
536+
}
537+
const contextMessage =
538+
errorDetails.length > 0
539+
? `Type: ${e.errorType}\n${errorDetails.join('\n\n')}`
540+
: `Type: ${e.errorType}`;
541+
542+
RovoDevLogger.error(renderError, contextMessage);
543+
break;
544+
522545
default:
523546
// @ts-expect-error ts(2339) - e here should be 'never'
524547
this.processError(new Error(`Unknown message type: ${e.type}`));
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import { render, screen, waitFor } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
5+
import { RovoDevErrorBoundary } from './RovoDevErrorBoundary';
6+
import { RovoDevViewResponseType } from './rovoDevViewMessages';
7+
8+
jest.mock('./common/DialogMessage', () => ({
9+
DialogMessageItem: ({ msg, customButton, onLinkClick }: any) => (
10+
<div data-testid="dialog-message-item">
11+
<div data-testid="dialog-title">{msg.title}</div>
12+
<div data-testid="dialog-text">{msg.text}</div>
13+
{msg.stackTrace && <div data-testid="dialog-stack-trace">{msg.stackTrace}</div>}
14+
{msg.stderr && <div data-testid="dialog-stderr">{msg.stderr}</div>}
15+
{customButton && (
16+
<button data-testid="custom-button" onClick={customButton.onClick}>
17+
{customButton.text}
18+
</button>
19+
)}
20+
<button data-testid="link-click" onClick={() => onLinkClick('test-link')}>
21+
Link
22+
</button>
23+
</div>
24+
),
25+
}));
26+
27+
const ThrowError: React.FC<{ shouldThrow?: boolean; errorMessage?: string }> = ({
28+
shouldThrow = true,
29+
errorMessage = 'Test error',
30+
}) => {
31+
if (shouldThrow) {
32+
throw new Error(errorMessage);
33+
}
34+
return <div>No error</div>;
35+
};
36+
37+
describe('RovoDevErrorBoundary', () => {
38+
let mockPostMessage: jest.Mock;
39+
let mockOnStartNewSession: jest.Mock;
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
mockPostMessage = jest.fn();
44+
mockOnStartNewSession = jest.fn();
45+
46+
jest.spyOn(console, 'error').mockImplementation(() => {});
47+
});
48+
49+
afterEach(() => {
50+
jest.restoreAllMocks();
51+
});
52+
53+
describe('Normal rendering', () => {
54+
it('renders children when there is no error', () => {
55+
render(
56+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
57+
<div>Test content</div>
58+
</RovoDevErrorBoundary>,
59+
);
60+
61+
expect(screen.getByText('Test content')).toBeTruthy();
62+
expect(screen.queryByTestId('dialog-message-item')).not.toBeTruthy();
63+
expect(mockPostMessage).not.toHaveBeenCalled();
64+
});
65+
});
66+
67+
describe('Error handling', () => {
68+
it('catches errors and displays error dialog', () => {
69+
render(
70+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
71+
<ThrowError />
72+
</RovoDevErrorBoundary>,
73+
);
74+
75+
expect(screen.getByTestId('dialog-message-item')).toBeTruthy();
76+
expect(screen.getByTestId('dialog-title')).toBeTruthy();
77+
expect(screen.getByText('Rovo Dev encountered a rendering error')).toBeTruthy();
78+
});
79+
80+
it('displays error message in dialog', () => {
81+
const errorMessage = 'Custom error message';
82+
render(
83+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
84+
<ThrowError errorMessage={errorMessage} />
85+
</RovoDevErrorBoundary>,
86+
);
87+
88+
expect(screen.getByTestId('dialog-text')).toBeTruthy();
89+
expect(screen.getByText(errorMessage)).toBeTruthy();
90+
});
91+
92+
it('displays default error message when error message is missing', () => {
93+
const ErrorWithoutMessage = () => {
94+
const error = new Error();
95+
error.message = '';
96+
throw error;
97+
};
98+
99+
render(
100+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
101+
<ErrorWithoutMessage />
102+
</RovoDevErrorBoundary>,
103+
);
104+
105+
expect(screen.getByText('An unexpected error occurred')).toBeTruthy();
106+
});
107+
108+
it('reports error to backend via postMessage', () => {
109+
const errorMessage = 'Test error message';
110+
render(
111+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
112+
<ThrowError errorMessage={errorMessage} />
113+
</RovoDevErrorBoundary>,
114+
);
115+
116+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
117+
expect(mockPostMessage).toHaveBeenCalledWith({
118+
type: RovoDevViewResponseType.ReportRenderError,
119+
errorType: 'Error',
120+
errorMessage: errorMessage,
121+
errorStack: expect.any(String),
122+
componentStack: expect.any(String),
123+
});
124+
});
125+
126+
it('includes error stack trace in dialog when available', () => {
127+
render(
128+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
129+
<ThrowError />
130+
</RovoDevErrorBoundary>,
131+
);
132+
133+
const stackTraceElement = screen.queryByTestId('dialog-stack-trace');
134+
expect(stackTraceElement).toBeTruthy();
135+
});
136+
137+
it('includes component stack in stderr field when available', () => {
138+
render(
139+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
140+
<ThrowError />
141+
</RovoDevErrorBoundary>,
142+
);
143+
144+
const stderrElement = screen.queryByTestId('dialog-stderr');
145+
expect(stderrElement).toBeTruthy();
146+
});
147+
});
148+
149+
describe('Start new session', () => {
150+
it('renders start new session button', () => {
151+
render(
152+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
153+
<ThrowError />
154+
</RovoDevErrorBoundary>,
155+
);
156+
157+
const button = screen.getByTestId('custom-button');
158+
expect(button).toBeTruthy();
159+
expect(screen.getByText('Start New Chat Session')).toBeTruthy();
160+
});
161+
162+
it('calls postMessage with StartNewSession when button is clicked', async () => {
163+
const user = userEvent.setup();
164+
render(
165+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
166+
<ThrowError />
167+
</RovoDevErrorBoundary>,
168+
);
169+
170+
const button = screen.getByTestId('custom-button');
171+
await user.click(button);
172+
173+
expect(mockPostMessage).toHaveBeenCalledWith({
174+
type: RovoDevViewResponseType.StartNewSession,
175+
});
176+
});
177+
178+
it('calls onStartNewSession callback when button is clicked', async () => {
179+
const user = userEvent.setup();
180+
render(
181+
<RovoDevErrorBoundary postMessage={mockPostMessage} onStartNewSession={mockOnStartNewSession}>
182+
<ThrowError />
183+
</RovoDevErrorBoundary>,
184+
);
185+
186+
const button = screen.getByTestId('custom-button');
187+
await user.click(button);
188+
189+
expect(mockOnStartNewSession).toHaveBeenCalledTimes(1);
190+
});
191+
192+
it('resets error state after starting new session', async () => {
193+
const user = userEvent.setup();
194+
const NoErrorComponent = () => <div>No error</div>;
195+
196+
// Use a wrapper component with state to control which children are rendered
197+
const TestWrapper = () => {
198+
const [shouldThrow, setShouldThrow] = React.useState(true);
199+
200+
const handleStartNewSession = () => {
201+
mockOnStartNewSession();
202+
setShouldThrow(false);
203+
};
204+
205+
return (
206+
<RovoDevErrorBoundary postMessage={mockPostMessage} onStartNewSession={handleStartNewSession}>
207+
{shouldThrow ? <ThrowError /> : <NoErrorComponent />}
208+
</RovoDevErrorBoundary>
209+
);
210+
};
211+
212+
render(<TestWrapper />);
213+
214+
expect(screen.getByTestId('dialog-message-item')).toBeTruthy();
215+
216+
const button = screen.getByTestId('custom-button');
217+
await user.click(button);
218+
219+
// Verify that postMessage and callback were called
220+
expect(mockPostMessage).toHaveBeenCalledWith({
221+
type: RovoDevViewResponseType.StartNewSession,
222+
});
223+
expect(mockOnStartNewSession).toHaveBeenCalledTimes(1);
224+
225+
await waitFor(() => {
226+
expect(screen.getByText('No error')).toBeTruthy();
227+
expect(screen.queryByTestId('dialog-message-item')).not.toBeTruthy();
228+
});
229+
});
230+
});
231+
232+
describe('Error boundary lifecycle', () => {
233+
it('updates state correctly when error is caught', () => {
234+
render(
235+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
236+
<ThrowError />
237+
</RovoDevErrorBoundary>,
238+
);
239+
240+
// Error should be caught and displayed
241+
expect(screen.getByTestId('dialog-message-item')).toBeTruthy();
242+
});
243+
244+
it('handles multiple errors correctly', () => {
245+
const { rerender } = render(
246+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
247+
<ThrowError errorMessage="First error" />
248+
</RovoDevErrorBoundary>,
249+
);
250+
251+
expect(screen.getByText('First error')).toBeTruthy();
252+
expect(mockPostMessage).toHaveBeenCalledTimes(1);
253+
254+
// Simulate a new error by re-rendering with a different error
255+
rerender(
256+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
257+
<ThrowError errorMessage="Second error" />
258+
</RovoDevErrorBoundary>,
259+
);
260+
261+
// Should still show error dialog
262+
expect(screen.getByTestId('dialog-message-item')).toBeTruthy();
263+
});
264+
});
265+
266+
describe('Link click handler', () => {
267+
it('provides empty link click handler', () => {
268+
render(
269+
<RovoDevErrorBoundary postMessage={mockPostMessage}>
270+
<ThrowError />
271+
</RovoDevErrorBoundary>,
272+
);
273+
274+
const linkButton = screen.getByTestId('link-click');
275+
expect(linkButton).toBeTruthy();
276+
277+
// Should not throw when clicked
278+
expect(() => {
279+
linkButton.click();
280+
}).not.toThrow();
281+
});
282+
});
283+
});

0 commit comments

Comments
 (0)