Skip to content

Commit 3e89b06

Browse files
KJ7LNWEric Wheeler
andauthored
debug: Add ErrorBoundary component for better error handling (RooCodeInc#5085)
Co-authored-by: Eric Wheeler <[email protected]>
1 parent b0820ac commit 3e89b06

35 files changed

+1175
-16
lines changed

pnpm-lock.yaml

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/.vscodeignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
!dist
1515

1616
# Include the built webview
17-
**/*.map
17+
!**/*.map
1818
!webview-ui/audio
1919
!webview-ui/build/assets/*.js
2020
!webview-ui/build/assets/*.ttf

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,7 @@ export class ClineProvider
682682
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
683683
`media-src ${webview.cspSource}`,
684684
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
685-
`connect-src https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
685+
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
686686
]
687687

688688
return /*html*/ `
@@ -764,7 +764,7 @@ export class ClineProvider
764764
<meta charset="utf-8">
765765
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
766766
<meta name="theme-color" content="#000000">
767-
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
767+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; font-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:; media-src ${webview.cspSource}; script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}' https://us-assets.i.posthog.com 'strict-dynamic'; connect-src ${webview.cspSource} https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com;">
768768
<link rel="stylesheet" type="text/css" href="${stylesUri}">
769769
<link href="${codiconsUri}" rel="stylesheet" />
770770
<script nonce="${nonce}">

src/core/webview/__tests__/ClineProvider.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ describe("ClineProvider", () => {
400400
options: {},
401401
onDidReceiveMessage: vi.fn(),
402402
asWebviewUri: vi.fn(),
403+
cspSource: "vscode-webview://test-csp-source",
403404
},
404405
visible: true,
405406
onDidDispose: vi.fn().mockImplementation((callback) => {
@@ -473,7 +474,7 @@ describe("ClineProvider", () => {
473474

474475
// Verify Content Security Policy contains the necessary PostHog domains
475476
expect(mockWebviewView.webview.html).toContain(
476-
"connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com",
477+
"connect-src vscode-webview://test-csp-source https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com",
477478
)
478479

479480
// Extract the script-src directive section and verify required security elements
@@ -1986,6 +1987,7 @@ describe("Project MCP Settings", () => {
19861987
options: {},
19871988
onDidReceiveMessage: vi.fn(),
19881989
asWebviewUri: vi.fn(),
1990+
cspSource: "vscode-webview://test-csp-source",
19891991
},
19901992
visible: true,
19911993
onDidDispose: vi.fn(),

src/esbuild.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ async function main() {
1515
const production = process.argv.includes("--production")
1616
const watch = process.argv.includes("--watch")
1717
const minify = production
18-
const sourcemap = !production
18+
const sourcemap = true // Always generate source maps for error handling
1919

2020
/**
2121
* @type {import('esbuild').BuildOptions}

webview-ui/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
"shell-quote": "^1.8.2",
6969
"shiki": "^3.2.1",
7070
"source-map": "^0.7.4",
71+
"stacktrace-js": "^2.0.2",
7172
"styled-components": "^6.1.13",
7273
"tailwind-merge": "^3.0.0",
7374
"tailwindcss": "^4.0.0",
@@ -90,6 +91,7 @@
9091
"@types/react": "^18.3.23",
9192
"@types/react-dom": "^18.3.5",
9293
"@types/shell-quote": "^1.7.5",
94+
"@types/stacktrace-js": "^2.0.3",
9395
"@types/vscode-webview": "^1.57.5",
9496
"@vitejs/plugin-react": "^4.3.4",
9597
"@vitest/ui": "^3.2.3",

webview-ui/src/App.tsx

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { MarketplaceViewStateManager } from "./components/marketplace/Marketplac
99
import { vscode } from "./utils/vscode"
1010
import { telemetryClient } from "./utils/TelemetryClient"
1111
import { TelemetryEventName } from "@roo-code/types"
12+
import { initializeSourceMaps, exposeSourceMapsForDebugging } from "./utils/sourceMapInitializer"
1213
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
1314
import ChatView, { ChatViewRef } from "./components/chat/ChatView"
1415
import HistoryView from "./components/history/HistoryView"
@@ -19,6 +20,7 @@ import { MarketplaceView } from "./components/marketplace/MarketplaceView"
1920
import ModesView from "./components/modes/ModesView"
2021
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
2122
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
23+
import ErrorBoundary from "./components/ErrorBoundary"
2224
import { AccountView } from "./components/account/AccountView"
2325
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
2426
import { TooltipProvider } from "./components/ui/tooltip"
@@ -191,6 +193,20 @@ const App = () => {
191193
// Tell the extension that we are ready to receive messages.
192194
useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), [])
193195

196+
// Initialize source map support for better error reporting
197+
useEffect(() => {
198+
// Initialize source maps for better error reporting in production
199+
initializeSourceMaps()
200+
201+
// Expose source map debugging utilities in production
202+
if (process.env.NODE_ENV === "production") {
203+
exposeSourceMapsForDebugging()
204+
}
205+
206+
// Log initialization for debugging
207+
console.debug("App initialized with source map support")
208+
}, [])
209+
194210
// Focus the WebView when non-interactive content is clicked (only in editor/tab mode)
195211
useAddNonInteractiveClickListener(
196212
useCallback(() => {
@@ -283,15 +299,17 @@ const App = () => {
283299
const queryClient = new QueryClient()
284300

285301
const AppWithProviders = () => (
286-
<ExtensionStateContextProvider>
287-
<TranslationProvider>
288-
<QueryClientProvider client={queryClient}>
289-
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
290-
<App />
291-
</TooltipProvider>
292-
</QueryClientProvider>
293-
</TranslationProvider>
294-
</ExtensionStateContextProvider>
302+
<ErrorBoundary>
303+
<ExtensionStateContextProvider>
304+
<TranslationProvider>
305+
<QueryClientProvider client={queryClient}>
306+
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
307+
<App />
308+
</TooltipProvider>
309+
</QueryClientProvider>
310+
</TranslationProvider>
311+
</ExtensionStateContextProvider>
312+
</ErrorBoundary>
295313
)
296314

297315
export default AppWithProviders

webview-ui/src/__tests__/App.spec.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,20 @@ vi.mock("@src/utils/vscode", () => ({
1111
},
1212
}))
1313

14+
// Mock the ErrorBoundary component
15+
vi.mock("@src/components/ErrorBoundary", () => ({
16+
__esModule: true,
17+
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
18+
}))
19+
20+
// Mock the telemetry client
21+
vi.mock("@src/utils/TelemetryClient", () => ({
22+
telemetryClient: {
23+
capture: vi.fn(),
24+
updateTelemetryState: vi.fn(),
25+
},
26+
}))
27+
1428
vi.mock("@src/components/chat/ChatView", () => ({
1529
__esModule: true,
1630
default: function ChatView({ isHidden }: { isHidden: boolean }) {
@@ -88,11 +102,81 @@ vi.mock("@src/components/account/AccountView", () => ({
88102

89103
const mockUseExtensionState = vi.fn()
90104

105+
// Mock the HumanRelayDialog component
106+
vi.mock("@src/components/human-relay/HumanRelayDialog", () => ({
107+
HumanRelayDialog: ({ _children, isOpen, onClose }: any) => (
108+
<div data-testid="human-relay-dialog" data-open={isOpen} onClick={onClose}>
109+
Human Relay Dialog
110+
</div>
111+
),
112+
}))
113+
114+
// Mock i18next and react-i18next
115+
vi.mock("i18next", () => {
116+
const tFunction = (key: string) => key
117+
const i18n = {
118+
t: tFunction,
119+
use: () => i18n,
120+
init: () => Promise.resolve(tFunction),
121+
changeLanguage: vi.fn(() => Promise.resolve()),
122+
}
123+
return { default: i18n }
124+
})
125+
126+
vi.mock("react-i18next", () => {
127+
const tFunction = (key: string) => key
128+
return {
129+
withTranslation: () => (Component: any) => {
130+
const MockedComponent = (props: any) => {
131+
return <Component t={tFunction} i18n={{ t: tFunction }} tReady {...props} />
132+
}
133+
MockedComponent.displayName = `withTranslation(${Component.displayName || Component.name || "Component"})`
134+
return MockedComponent
135+
},
136+
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
137+
useTranslation: () => {
138+
return {
139+
t: tFunction,
140+
i18n: {
141+
t: tFunction,
142+
changeLanguage: vi.fn(() => Promise.resolve()),
143+
},
144+
}
145+
},
146+
initReactI18next: {
147+
type: "3rdParty",
148+
init: vi.fn(),
149+
},
150+
}
151+
})
152+
153+
// Mock TranslationProvider to pass through children
154+
vi.mock("@src/i18n/TranslationContext", () => {
155+
const tFunction = (key: string) => key
156+
return {
157+
__esModule: true,
158+
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
159+
useAppTranslation: () => ({
160+
t: tFunction,
161+
i18n: {
162+
t: tFunction,
163+
changeLanguage: vi.fn(() => Promise.resolve()),
164+
},
165+
}),
166+
}
167+
})
168+
91169
vi.mock("@src/context/ExtensionStateContext", () => ({
92170
useExtensionState: () => mockUseExtensionState(),
93171
ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
94172
}))
95173

174+
// Mock environment variables
175+
vi.mock("process.env", () => ({
176+
NODE_ENV: "test",
177+
PKG_VERSION: "1.0.0-test",
178+
}))
179+
96180
describe("App", () => {
97181
beforeEach(() => {
98182
vi.clearAllMocks()
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import React from "react"
2+
import { render, screen } from "@testing-library/react"
3+
import ErrorBoundary from "../components/ErrorBoundary"
4+
5+
// Mock telemetry client
6+
vi.mock("@src/utils/TelemetryClient", () => ({
7+
telemetryClient: {
8+
capture: vi.fn(),
9+
},
10+
}))
11+
12+
// Mock translation function
13+
vi.mock("react-i18next", () => {
14+
const tFunction = (key: string) => key
15+
return {
16+
withTranslation: () => (Component: any) => {
17+
const MockedComponent = (props: any) => {
18+
return <Component t={tFunction} i18n={{ t: tFunction }} tReady {...props} />
19+
}
20+
MockedComponent.displayName = `withTranslation(${Component.displayName || Component.name || "Component"})`
21+
return MockedComponent
22+
},
23+
}
24+
})
25+
26+
// Test component that can throw errors on demand
27+
const ErrorThrower = ({ shouldThrow = false, message = "Test error" }: { shouldThrow?: boolean; message?: string }) => {
28+
if (shouldThrow) {
29+
throw new Error(message)
30+
}
31+
return <div>No error</div>
32+
}
33+
34+
describe("ErrorBoundary", () => {
35+
// Suppress console errors during tests
36+
beforeEach(() => {
37+
vi.spyOn(console, "error").mockImplementation(() => {})
38+
})
39+
40+
afterEach(() => {
41+
vi.restoreAllMocks()
42+
})
43+
44+
it("renders children when there is no error", () => {
45+
render(
46+
<ErrorBoundary>
47+
<div data-testid="test-child">Test Content</div>
48+
</ErrorBoundary>,
49+
)
50+
51+
expect(screen.getByTestId("test-child")).toBeInTheDocument()
52+
expect(screen.getByText("Test Content")).toBeInTheDocument()
53+
})
54+
55+
it("renders error UI when a child component throws", () => {
56+
vi.stubEnv("PKG_VERSION", "1.2.3")
57+
58+
// Using the React testing library's render method with an error boundary is tricky
59+
// We need to catch and ignore the error during the test
60+
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
61+
62+
render(
63+
<ErrorBoundary>
64+
<ErrorThrower shouldThrow={true} message="Test component error" />
65+
</ErrorBoundary>,
66+
)
67+
68+
// Verify error boundary elements are displayed - using partial matchers to account for version info
69+
expect(screen.getByText(/errorBoundary.title/)).toBeInTheDocument()
70+
71+
// Check for the GitHub link
72+
const githubLink = screen.getByRole("link", { name: /errorBoundary.githubText/ })
73+
expect(githubLink).toBeInTheDocument()
74+
expect(githubLink).toHaveAttribute("href", "https://github.com/RooCodeInc/Roo-Code/issues")
75+
76+
// Check for other error boundary elements
77+
expect(screen.getByText(/errorBoundary.copyInstructions/)).toBeInTheDocument()
78+
expect(screen.getByText(/errorBoundary.errorStack/)).toBeInTheDocument()
79+
80+
// In test environments, the componentStack might not always be available
81+
// so we don't check for it to make the test more reliable
82+
83+
// The test error message should be included in the error display
84+
expect(screen.getByText(/Test component error/)).toBeInTheDocument()
85+
86+
spy.mockRestore()
87+
})
88+
})

0 commit comments

Comments
 (0)