mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
debug: Add ErrorBoundary component for better error handling (#5085)
Co-authored-by: Eric Wheeler <roo-code@z.ewheeler.org>
This commit is contained in:
parent
b0820ac755
commit
3e89b06f25
35 changed files with 1175 additions and 16 deletions
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
|
@ -1036,6 +1036,9 @@ importers:
|
|||
source-map:
|
||||
specifier: ^0.7.4
|
||||
version: 0.7.4
|
||||
stacktrace-js:
|
||||
specifier: ^2.0.2
|
||||
version: 2.0.2
|
||||
styled-components:
|
||||
specifier: ^6.1.13
|
||||
version: 6.1.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -1097,6 +1100,9 @@ importers:
|
|||
'@types/shell-quote':
|
||||
specifier: ^1.7.5
|
||||
version: 1.7.5
|
||||
'@types/stacktrace-js':
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
'@types/vscode-webview':
|
||||
specifier: ^1.57.5
|
||||
version: 1.57.5
|
||||
|
|
@ -3888,6 +3894,10 @@ packages:
|
|||
'@types/stack-utils@2.0.3':
|
||||
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
|
||||
|
||||
'@types/stacktrace-js@2.0.3':
|
||||
resolution: {integrity: sha512-B6JnMic4NAZ4mLWmRi4RvayCN2HZQvpcVF0MkoqubtuZx1AQB0/kRlrngGiocEPyO7R+TFocTEoLKQ0HzmEOPw==}
|
||||
deprecated: This is a stub types definition. stacktrace-js provides its own type definitions, so you do not need this installed.
|
||||
|
||||
'@types/stream-chain@2.1.0':
|
||||
resolution: {integrity: sha512-guDyAl6s/CAzXUOWpGK2bHvdiopLIwpGu8v10+lb9hnQOyo4oj/ZUQFOvqFjKGsE3wJP1fpIesCcMvbXuWsqOg==}
|
||||
|
||||
|
|
@ -13090,6 +13100,10 @@ snapshots:
|
|||
|
||||
'@types/stack-utils@2.0.3': {}
|
||||
|
||||
'@types/stacktrace-js@2.0.3':
|
||||
dependencies:
|
||||
stacktrace-js: 2.0.2
|
||||
|
||||
'@types/stream-chain@2.1.0':
|
||||
dependencies:
|
||||
'@types/node': 20.19.1
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
!dist
|
||||
|
||||
# Include the built webview
|
||||
**/*.map
|
||||
!**/*.map
|
||||
!webview-ui/audio
|
||||
!webview-ui/build/assets/*.js
|
||||
!webview-ui/build/assets/*.ttf
|
||||
|
|
|
|||
|
|
@ -682,7 +682,7 @@ export class ClineProvider
|
|||
`img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com data:`,
|
||||
`media-src ${webview.cspSource}`,
|
||||
`script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`connect-src ${webview.cspSource} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
return /*html*/ `
|
||||
|
|
@ -764,7 +764,7 @@ export class ClineProvider
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<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;">
|
||||
<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;">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<script nonce="${nonce}">
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@ describe("ClineProvider", () => {
|
|||
options: {},
|
||||
onDidReceiveMessage: vi.fn(),
|
||||
asWebviewUri: vi.fn(),
|
||||
cspSource: "vscode-webview://test-csp-source",
|
||||
},
|
||||
visible: true,
|
||||
onDidDispose: vi.fn().mockImplementation((callback) => {
|
||||
|
|
@ -473,7 +474,7 @@ describe("ClineProvider", () => {
|
|||
|
||||
// Verify Content Security Policy contains the necessary PostHog domains
|
||||
expect(mockWebviewView.webview.html).toContain(
|
||||
"connect-src https://openrouter.ai https://api.requesty.ai https://us.i.posthog.com https://us-assets.i.posthog.com",
|
||||
"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",
|
||||
)
|
||||
|
||||
// Extract the script-src directive section and verify required security elements
|
||||
|
|
@ -1986,6 +1987,7 @@ describe("Project MCP Settings", () => {
|
|||
options: {},
|
||||
onDidReceiveMessage: vi.fn(),
|
||||
asWebviewUri: vi.fn(),
|
||||
cspSource: "vscode-webview://test-csp-source",
|
||||
},
|
||||
visible: true,
|
||||
onDidDispose: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ async function main() {
|
|||
const production = process.argv.includes("--production")
|
||||
const watch = process.argv.includes("--watch")
|
||||
const minify = production
|
||||
const sourcemap = !production
|
||||
const sourcemap = true // Always generate source maps for error handling
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').BuildOptions}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@
|
|||
"shell-quote": "^1.8.2",
|
||||
"shiki": "^3.2.1",
|
||||
"source-map": "^0.7.4",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"styled-components": "^6.1.13",
|
||||
"tailwind-merge": "^3.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
|
|
@ -90,6 +91,7 @@
|
|||
"@types/react": "^18.3.23",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/stacktrace-js": "^2.0.3",
|
||||
"@types/vscode-webview": "^1.57.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/ui": "^3.2.3",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { MarketplaceViewStateManager } from "./components/marketplace/Marketplac
|
|||
import { vscode } from "./utils/vscode"
|
||||
import { telemetryClient } from "./utils/TelemetryClient"
|
||||
import { TelemetryEventName } from "@roo-code/types"
|
||||
import { initializeSourceMaps, exposeSourceMapsForDebugging } from "./utils/sourceMapInitializer"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./context/ExtensionStateContext"
|
||||
import ChatView, { ChatViewRef } from "./components/chat/ChatView"
|
||||
import HistoryView from "./components/history/HistoryView"
|
||||
|
|
@ -19,6 +20,7 @@ import { MarketplaceView } from "./components/marketplace/MarketplaceView"
|
|||
import ModesView from "./components/modes/ModesView"
|
||||
import { HumanRelayDialog } from "./components/human-relay/HumanRelayDialog"
|
||||
import { DeleteMessageDialog, EditMessageDialog } from "./components/chat/MessageModificationConfirmationDialog"
|
||||
import ErrorBoundary from "./components/ErrorBoundary"
|
||||
import { AccountView } from "./components/account/AccountView"
|
||||
import { useAddNonInteractiveClickListener } from "./components/ui/hooks/useNonInteractiveClick"
|
||||
import { TooltipProvider } from "./components/ui/tooltip"
|
||||
|
|
@ -191,6 +193,20 @@ const App = () => {
|
|||
// Tell the extension that we are ready to receive messages.
|
||||
useEffect(() => vscode.postMessage({ type: "webviewDidLaunch" }), [])
|
||||
|
||||
// Initialize source map support for better error reporting
|
||||
useEffect(() => {
|
||||
// Initialize source maps for better error reporting in production
|
||||
initializeSourceMaps()
|
||||
|
||||
// Expose source map debugging utilities in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
exposeSourceMapsForDebugging()
|
||||
}
|
||||
|
||||
// Log initialization for debugging
|
||||
console.debug("App initialized with source map support")
|
||||
}, [])
|
||||
|
||||
// Focus the WebView when non-interactive content is clicked (only in editor/tab mode)
|
||||
useAddNonInteractiveClickListener(
|
||||
useCallback(() => {
|
||||
|
|
@ -283,15 +299,17 @@ const App = () => {
|
|||
const queryClient = new QueryClient()
|
||||
|
||||
const AppWithProviders = () => (
|
||||
<ExtensionStateContextProvider>
|
||||
<TranslationProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</TranslationProvider>
|
||||
</ExtensionStateContextProvider>
|
||||
<ErrorBoundary>
|
||||
<ExtensionStateContextProvider>
|
||||
<TranslationProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delayDuration={STANDARD_TOOLTIP_DELAY}>
|
||||
<App />
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</TranslationProvider>
|
||||
</ExtensionStateContextProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
export default AppWithProviders
|
||||
|
|
|
|||
|
|
@ -11,6 +11,20 @@ vi.mock("@src/utils/vscode", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
// Mock the ErrorBoundary component
|
||||
vi.mock("@src/components/ErrorBoundary", () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
// Mock the telemetry client
|
||||
vi.mock("@src/utils/TelemetryClient", () => ({
|
||||
telemetryClient: {
|
||||
capture: vi.fn(),
|
||||
updateTelemetryState: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/chat/ChatView", () => ({
|
||||
__esModule: true,
|
||||
default: function ChatView({ isHidden }: { isHidden: boolean }) {
|
||||
|
|
@ -88,11 +102,81 @@ vi.mock("@src/components/account/AccountView", () => ({
|
|||
|
||||
const mockUseExtensionState = vi.fn()
|
||||
|
||||
// Mock the HumanRelayDialog component
|
||||
vi.mock("@src/components/human-relay/HumanRelayDialog", () => ({
|
||||
HumanRelayDialog: ({ _children, isOpen, onClose }: any) => (
|
||||
<div data-testid="human-relay-dialog" data-open={isOpen} onClick={onClose}>
|
||||
Human Relay Dialog
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock i18next and react-i18next
|
||||
vi.mock("i18next", () => {
|
||||
const tFunction = (key: string) => key
|
||||
const i18n = {
|
||||
t: tFunction,
|
||||
use: () => i18n,
|
||||
init: () => Promise.resolve(tFunction),
|
||||
changeLanguage: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
return { default: i18n }
|
||||
})
|
||||
|
||||
vi.mock("react-i18next", () => {
|
||||
const tFunction = (key: string) => key
|
||||
return {
|
||||
withTranslation: () => (Component: any) => {
|
||||
const MockedComponent = (props: any) => {
|
||||
return <Component t={tFunction} i18n={{ t: tFunction }} tReady {...props} />
|
||||
}
|
||||
MockedComponent.displayName = `withTranslation(${Component.displayName || Component.name || "Component"})`
|
||||
return MockedComponent
|
||||
},
|
||||
Trans: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useTranslation: () => {
|
||||
return {
|
||||
t: tFunction,
|
||||
i18n: {
|
||||
t: tFunction,
|
||||
changeLanguage: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}
|
||||
},
|
||||
initReactI18next: {
|
||||
type: "3rdParty",
|
||||
init: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock TranslationProvider to pass through children
|
||||
vi.mock("@src/i18n/TranslationContext", () => {
|
||||
const tFunction = (key: string) => key
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
useAppTranslation: () => ({
|
||||
t: tFunction,
|
||||
i18n: {
|
||||
t: tFunction,
|
||||
changeLanguage: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => mockUseExtensionState(),
|
||||
ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("process.env", () => ({
|
||||
NODE_ENV: "test",
|
||||
PKG_VERSION: "1.0.0-test",
|
||||
}))
|
||||
|
||||
describe("App", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
88
webview-ui/src/__tests__/ErrorBoundary.spec.tsx
Normal file
88
webview-ui/src/__tests__/ErrorBoundary.spec.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import ErrorBoundary from "../components/ErrorBoundary"
|
||||
|
||||
// Mock telemetry client
|
||||
vi.mock("@src/utils/TelemetryClient", () => ({
|
||||
telemetryClient: {
|
||||
capture: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock translation function
|
||||
vi.mock("react-i18next", () => {
|
||||
const tFunction = (key: string) => key
|
||||
return {
|
||||
withTranslation: () => (Component: any) => {
|
||||
const MockedComponent = (props: any) => {
|
||||
return <Component t={tFunction} i18n={{ t: tFunction }} tReady {...props} />
|
||||
}
|
||||
MockedComponent.displayName = `withTranslation(${Component.displayName || Component.name || "Component"})`
|
||||
return MockedComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Test component that can throw errors on demand
|
||||
const ErrorThrower = ({ shouldThrow = false, message = "Test error" }: { shouldThrow?: boolean; message?: string }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return <div>No error</div>
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
// Suppress console errors during tests
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("renders children when there is no error", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<div data-testid="test-child">Test Content</div>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("test-child")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders error UI when a child component throws", () => {
|
||||
vi.stubEnv("PKG_VERSION", "1.2.3")
|
||||
|
||||
// Using the React testing library's render method with an error boundary is tricky
|
||||
// We need to catch and ignore the error during the test
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ErrorThrower shouldThrow={true} message="Test component error" />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
// Verify error boundary elements are displayed - using partial matchers to account for version info
|
||||
expect(screen.getByText(/errorBoundary.title/)).toBeInTheDocument()
|
||||
|
||||
// Check for the GitHub link
|
||||
const githubLink = screen.getByRole("link", { name: /errorBoundary.githubText/ })
|
||||
expect(githubLink).toBeInTheDocument()
|
||||
expect(githubLink).toHaveAttribute("href", "https://github.com/RooCodeInc/Roo-Code/issues")
|
||||
|
||||
// Check for other error boundary elements
|
||||
expect(screen.getByText(/errorBoundary.copyInstructions/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/errorBoundary.errorStack/)).toBeInTheDocument()
|
||||
|
||||
// In test environments, the componentStack might not always be available
|
||||
// so we don't check for it to make the test more reliable
|
||||
|
||||
// The test error message should be included in the error display
|
||||
expect(screen.getByText(/Test component error/)).toBeInTheDocument()
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
98
webview-ui/src/components/ErrorBoundary.tsx
Normal file
98
webview-ui/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import React, { Component } from "react"
|
||||
import { telemetryClient } from "@src/utils/TelemetryClient"
|
||||
import { withTranslation, WithTranslation } from "react-i18next"
|
||||
import { enhanceErrorWithSourceMaps } from "@src/utils/sourceMapUtils"
|
||||
|
||||
type ErrorProps = {
|
||||
children: React.ReactNode
|
||||
} & WithTranslation
|
||||
|
||||
type ErrorState = {
|
||||
error?: string
|
||||
componentStack?: string | null
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<ErrorProps, ErrorState> {
|
||||
constructor(props: ErrorProps) {
|
||||
super(props)
|
||||
this.state = {}
|
||||
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: unknown) {
|
||||
let errorMessage = ""
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error.stack ?? error.message
|
||||
} else {
|
||||
errorMessage = `${error}`
|
||||
}
|
||||
|
||||
return {
|
||||
error: errorMessage,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
const componentStack = errorInfo.componentStack || ""
|
||||
const enhancedError = await enhanceErrorWithSourceMaps(error, componentStack)
|
||||
|
||||
telemetryClient.capture("error_boundary_caught_error", {
|
||||
error: enhancedError.message,
|
||||
stack: enhancedError.sourceMappedStack || enhancedError.stack,
|
||||
componentStack: enhancedError.sourceMappedComponentStack || componentStack,
|
||||
timestamp: Date.now(),
|
||||
errorType: enhancedError.name,
|
||||
})
|
||||
|
||||
this.setState({
|
||||
error: enhancedError.sourceMappedStack || enhancedError.stack,
|
||||
componentStack: enhancedError.sourceMappedComponentStack || componentStack,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
const { t } = this.props
|
||||
|
||||
if (!this.state.error) {
|
||||
return this.props.children
|
||||
}
|
||||
|
||||
const errorDisplay = this.state.error
|
||||
const componentStackDisplay = this.state.componentStack
|
||||
|
||||
const version = process.env.PKG_VERSION || "unknown"
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-bold mt-0 mb-2">
|
||||
{t("errorBoundary.title")} (v{version})
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
{t("errorBoundary.reportText")}{" "}
|
||||
<a href="https://github.com/RooCodeInc/Roo-Code/issues" target="_blank" rel="noreferrer">
|
||||
{t("errorBoundary.githubText")}
|
||||
</a>
|
||||
</p>
|
||||
<p className="mb-2">{t("errorBoundary.copyInstructions")}</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<h3 className="text-md font-bold mb-1">{t("errorBoundary.errorStack")}</h3>
|
||||
<pre className="p-2 border rounded text-sm overflow-auto">{errorDisplay}</pre>
|
||||
</div>
|
||||
|
||||
{componentStackDisplay && (
|
||||
<div>
|
||||
<h3 className="text-md font-bold mb-1">{t("errorBoundary.componentStack")}</h3>
|
||||
<pre className="p-2 border rounded text-sm overflow-auto">{componentStackDisplay}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withTranslation("common")(ErrorBoundary)
|
||||
97
webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx
Normal file
97
webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from "react"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { vi } from "vitest"
|
||||
import ErrorBoundary from "../ErrorBoundary"
|
||||
|
||||
// Mock telemetryClient
|
||||
vi.mock("@src/utils/TelemetryClient", () => ({
|
||||
telemetryClient: {
|
||||
capture: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock translation
|
||||
vi.mock("react-i18next", () => ({
|
||||
withTranslation: () => (Component: any) => {
|
||||
Component.defaultProps = {
|
||||
...Component.defaultProps,
|
||||
t: (key: string) => {
|
||||
// Mock translations for tests
|
||||
const translations: Record<string, string> = {
|
||||
"errorBoundary.title": "Something went wrong",
|
||||
"errorBoundary.reportText": "Please help us improve by reporting this error on",
|
||||
"errorBoundary.githubText": "GitHub",
|
||||
"errorBoundary.copyInstructions": "Please copy and paste the following error message:",
|
||||
}
|
||||
return translations[key] || key
|
||||
},
|
||||
}
|
||||
return Component
|
||||
},
|
||||
}))
|
||||
|
||||
// Test component that throws an error
|
||||
const ErrorThrowingComponent = ({ shouldThrow = false }) => {
|
||||
if (shouldThrow) {
|
||||
throw new Error("Test error")
|
||||
}
|
||||
return <div data-testid="normal-render">Content rendered normally</div>
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
// Suppress console errors during tests
|
||||
const originalConsoleError = console.error
|
||||
beforeAll(() => {
|
||||
console.error = vi.fn()
|
||||
})
|
||||
afterAll(() => {
|
||||
console.error = originalConsoleError
|
||||
})
|
||||
|
||||
test("renders children when no error occurs", () => {
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ErrorThrowingComponent shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("normal-render")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("renders error UI when an error occurs", () => {
|
||||
// React will log the error to the console - we're just testing the UI behavior
|
||||
render(
|
||||
<ErrorBoundary>
|
||||
<ErrorThrowingComponent shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
// Verify error message is displayed using a more flexible approach
|
||||
const errorTitle = screen.getByRole("heading", { level: 2 })
|
||||
expect(errorTitle.textContent).toContain("Something went wrong")
|
||||
expect(screen.getByText(/please copy and paste the following error message/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test("error boundary renders error UI when component changes but still in error state", () => {
|
||||
const { rerender } = render(
|
||||
<ErrorBoundary>
|
||||
<ErrorThrowingComponent shouldThrow={true} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
// Verify error message is displayed using a more flexible approach
|
||||
const errorTitle = screen.getByRole("heading", { level: 2 })
|
||||
expect(errorTitle.textContent).toContain("Something went wrong")
|
||||
|
||||
// Update the component to not throw
|
||||
rerender(
|
||||
<ErrorBoundary>
|
||||
<ErrorThrowingComponent shouldThrow={false} />
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
// The error boundary should still show the error since it doesn't automatically reset
|
||||
const errorTitleAfterRerender = screen.getByRole("heading", { level: 2 })
|
||||
expect(errorTitleAfterRerender.textContent).toContain("Something went wrong")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { HTMLAttributes } from "react"
|
||||
import { HTMLAttributes, useState } from "react"
|
||||
import { useAppTranslation } from "@/i18n/TranslationContext"
|
||||
import { Trans } from "react-i18next"
|
||||
import { Info, Download, Upload, TriangleAlert } from "lucide-react"
|
||||
|
|
@ -22,9 +22,33 @@ type AboutProps = HTMLAttributes<HTMLDivElement> & {
|
|||
|
||||
export const About = ({ telemetrySetting, setTelemetrySetting, className, ...props }: AboutProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
const [shouldThrowError, setShouldThrowError] = useState(false)
|
||||
|
||||
// Function to trigger error for testing ErrorBoundary
|
||||
const triggerTestError = () => {
|
||||
setShouldThrowError(true)
|
||||
}
|
||||
|
||||
// Named function to make it easier to identify in stack traces
|
||||
function throwTestError() {
|
||||
// Intentionally cause a type error by accessing a property on undefined
|
||||
const obj: any = undefined
|
||||
obj.nonExistentMethod()
|
||||
}
|
||||
|
||||
// Test component that throws an error when shouldThrow is true
|
||||
const ErrorThrower = ({ shouldThrow = false }) => {
|
||||
if (shouldThrow) {
|
||||
// Use a named function to make it easier to identify in stack traces
|
||||
throwTestError()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)} {...props}>
|
||||
{/* Test component that throws an error when shouldThrow is true */}
|
||||
<ErrorThrower shouldThrow={shouldThrowError} />
|
||||
<SectionHeader
|
||||
description={
|
||||
Package.sha
|
||||
|
|
@ -84,6 +108,12 @@ export const About = ({ telemetrySetting, setTelemetrySetting, className, ...pro
|
|||
<TriangleAlert className="p-0.5" />
|
||||
{t("settings:footer.settings.reset")}
|
||||
</Button>
|
||||
|
||||
{/* Test button for ErrorBoundary - only visible in development */}
|
||||
<Button variant="destructive" onClick={triggerTestError} className="w-auto">
|
||||
<TriangleAlert className="p-0.5" />
|
||||
Test ErrorBoundary
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ca/common.json
generated
8
webview-ui/src/i18n/locales/ca/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Alguna cosa ha anat malament",
|
||||
"reportText": "Si us plau, ajuda'ns a millorar informant d'aquest error a",
|
||||
"githubText": "la nostra pàgina d'incidències de GitHub",
|
||||
"copyInstructions": "Copia i enganxa el següent missatge d'error per incloure'l com a part de la teva presentació:",
|
||||
"errorStack": "Pila d'errors:",
|
||||
"componentStack": "Pila de components:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/de/common.json
generated
8
webview-ui/src/i18n/locales/de/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Etwas ist schiefgelaufen",
|
||||
"reportText": "Bitte hilf uns, besser zu werden, indem du diesen Fehler meldest auf",
|
||||
"githubText": "unserer GitHub Issues-Seite",
|
||||
"copyInstructions": "Kopiere die folgende Fehlermeldung und füge sie deiner Meldung bei:",
|
||||
"errorStack": "Fehler-Stack:",
|
||||
"componentStack": "Komponenten-Stack:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"reportText": "Please help us improve by reporting this error on",
|
||||
"githubText": "our GitHub Issues page",
|
||||
"copyInstructions": "Copy and paste the following error message to include it as part of your submission:",
|
||||
"errorStack": "Error Stack:",
|
||||
"componentStack": "Component Stack:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/es/common.json
generated
8
webview-ui/src/i18n/locales/es/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Algo salió mal",
|
||||
"reportText": "Ayúdanos a mejorar informando de este error en",
|
||||
"githubText": "nuestra página de Issues de GitHub",
|
||||
"copyInstructions": "Copia y pega el siguiente mensaje de error para incluirlo como parte de tu informe:",
|
||||
"errorStack": "Pila de errores:",
|
||||
"componentStack": "Pila de componentes:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/fr/common.json
generated
8
webview-ui/src/i18n/locales/fr/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur s'est produite",
|
||||
"reportText": "Aidez-nous à nous améliorer en signalant cette erreur sur",
|
||||
"githubText": "notre page GitHub Issues",
|
||||
"copyInstructions": "Copiez et collez le message d'erreur suivant pour l'inclure dans votre rapport :",
|
||||
"errorStack": "Pile d'erreurs :",
|
||||
"componentStack": "Pile de composants :"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/hi/common.json
generated
8
webview-ui/src/i18n/locales/hi/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "कुछ गलत हो गया",
|
||||
"reportText": "कृपया इस त्रुटि की रिपोर्ट करके हमें सुधारने में मदद करें",
|
||||
"githubText": "हमारे GitHub Issues पेज पर",
|
||||
"copyInstructions": "अपनी सबमिशन के हिस्से के रूप में शामिल करने के लिए निम्नलिखित त्रुटि संदेश को कॉपी और पेस्ट करें:",
|
||||
"errorStack": "त्रुटि स्टैक:",
|
||||
"componentStack": "कंपोनेंट स्टैक:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "हाँ",
|
||||
"no": "नहीं",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/id/common.json
generated
8
webview-ui/src/i18n/locales/id/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Terjadi kesalahan",
|
||||
"reportText": "Bantu kami meningkatkan dengan melaporkan kesalahan ini di",
|
||||
"githubText": "halaman GitHub Issues kami",
|
||||
"copyInstructions": "Salin dan tempel pesan kesalahan berikut untuk menyertakannya sebagai bagian dari laporan Anda:",
|
||||
"errorStack": "Stack Error:",
|
||||
"componentStack": "Stack Komponen:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Ya",
|
||||
"no": "Tidak",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/it/common.json
generated
8
webview-ui/src/i18n/locales/it/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
"reportText": "Aiutaci a migliorare segnalando questo errore su",
|
||||
"githubText": "la nostra pagina GitHub Issues",
|
||||
"copyInstructions": "Copia e incolla il seguente messaggio di errore per includerlo come parte della tua segnalazione:",
|
||||
"errorStack": "Stack di errore:",
|
||||
"componentStack": "Stack dei componenti:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ja/common.json
generated
8
webview-ui/src/i18n/locales/ja/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "問題が発生しました",
|
||||
"reportText": "このエラーを報告して改善にご協力ください",
|
||||
"githubText": "GitHub Issuesページ",
|
||||
"copyInstructions": "以下のエラーメッセージをコピーして報告に含めてください:",
|
||||
"errorStack": "エラースタック:",
|
||||
"componentStack": "コンポーネントスタック:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ko/common.json
generated
8
webview-ui/src/i18n/locales/ko/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "문제가 발생했습니다",
|
||||
"reportText": "이 오류를 보고하여 개선에 도움을 주세요",
|
||||
"githubText": "GitHub 이슈 페이지",
|
||||
"copyInstructions": "다음 오류 메시지를 복사하여 보고서에 포함해 주세요:",
|
||||
"errorStack": "오류 스택:",
|
||||
"componentStack": "컴포넌트 스택:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "예",
|
||||
"no": "아니오",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/nl/common.json
generated
8
webview-ui/src/i18n/locales/nl/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Er is iets misgegaan",
|
||||
"reportText": "Help ons te verbeteren door deze fout te melden op",
|
||||
"githubText": "onze GitHub Issues-pagina",
|
||||
"copyInstructions": "Kopieer en plak het volgende foutbericht om het als onderdeel van je melding op te nemen:",
|
||||
"errorStack": "Foutstack:",
|
||||
"componentStack": "Componentstack:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/pl/common.json
generated
8
webview-ui/src/i18n/locales/pl/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
"reportText": "Pomóż nam ulepszyć aplikację, zgłaszając ten błąd na",
|
||||
"githubText": "naszej stronie GitHub Issues",
|
||||
"copyInstructions": "Skopiuj i wklej poniższy komunikat o błędzie, aby dołączyć go do zgłoszenia:",
|
||||
"errorStack": "Stos błędu:",
|
||||
"componentStack": "Stos komponentów:"
|
||||
},
|
||||
"number_format": {
|
||||
"thousand_suffix": "k",
|
||||
"million_suffix": "m",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/pt-BR/common.json
generated
8
webview-ui/src/i18n/locales/pt-BR/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Algo deu errado",
|
||||
"reportText": "Ajude-nos a melhorar relatando este erro em",
|
||||
"githubText": "nossa página de Issues no GitHub",
|
||||
"copyInstructions": "Copie e cole a seguinte mensagem de erro para incluí-la como parte do seu relatório:",
|
||||
"errorStack": "Pilha de Erro:",
|
||||
"componentStack": "Pilha de Componentes:"
|
||||
},
|
||||
"number_format": {
|
||||
"thousand_suffix": "k",
|
||||
"million_suffix": "m",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/ru/common.json
generated
8
webview-ui/src/i18n/locales/ru/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Что-то пошло не так",
|
||||
"reportText": "Пожалуйста, помогите нам улучшить приложение, сообщив об этой ошибке на",
|
||||
"githubText": "нашей странице GitHub Issues",
|
||||
"copyInstructions": "Скопируйте и вставьте следующее сообщение об ошибке, чтобы включить его в ваше сообщение:",
|
||||
"errorStack": "Стек ошибки:",
|
||||
"componentStack": "Стек компонентов:"
|
||||
},
|
||||
"number_format": {
|
||||
"thousand_suffix": "тыс",
|
||||
"million_suffix": "млн",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/tr/common.json
generated
8
webview-ui/src/i18n/locales/tr/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Bir şeyler yanlış gitti",
|
||||
"reportText": "Bu hatayı bildirerek iyileştirmemize yardımcı olun",
|
||||
"githubText": "GitHub Issues sayfamızda",
|
||||
"copyInstructions": "Gönderiminize dahil etmek için aşağıdaki hata mesajını kopyalayıp yapıştırın:",
|
||||
"errorStack": "Hata Yığını:",
|
||||
"componentStack": "Bileşen Yığını:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Evet",
|
||||
"no": "Hayır",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/vi/common.json
generated
8
webview-ui/src/i18n/locales/vi/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "Đã xảy ra lỗi",
|
||||
"reportText": "Vui lòng giúp chúng tôi cải thiện bằng cách báo cáo lỗi này trên",
|
||||
"githubText": "trang GitHub Issues của chúng tôi",
|
||||
"copyInstructions": "Sao chép và dán thông báo lỗi sau đây để đưa vào báo cáo của bạn:",
|
||||
"errorStack": "Stack lỗi:",
|
||||
"componentStack": "Stack thành phần:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "Có",
|
||||
"no": "Không",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/zh-CN/common.json
generated
8
webview-ui/src/i18n/locales/zh-CN/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "出现了错误",
|
||||
"reportText": "请通过在以下位置报告此错误来帮助我们改进",
|
||||
"githubText": "我们的 GitHub Issues 页面",
|
||||
"copyInstructions": "复制并粘贴以下错误信息,将其作为提交内容的一部分:",
|
||||
"errorStack": "错误堆栈:",
|
||||
"componentStack": "组件堆栈:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
|
|
|
|||
8
webview-ui/src/i18n/locales/zh-TW/common.json
generated
8
webview-ui/src/i18n/locales/zh-TW/common.json
generated
|
|
@ -1,4 +1,12 @@
|
|||
{
|
||||
"errorBoundary": {
|
||||
"title": "發生錯誤",
|
||||
"reportText": "請幫助我們改進,在以下位置回報此錯誤",
|
||||
"githubText": "我們的 GitHub Issues 頁面",
|
||||
"copyInstructions": "複製並貼上以下錯誤訊息,將其作為提交內容的一部分:",
|
||||
"errorStack": "錯誤堆疊:",
|
||||
"componentStack": "元件堆疊:"
|
||||
},
|
||||
"answers": {
|
||||
"yes": "是",
|
||||
"no": "否",
|
||||
|
|
|
|||
88
webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts
Normal file
88
webview-ui/src/utils/__tests__/sourceMapUtils.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { vi, describe, test, expect, beforeEach } from "vitest"
|
||||
import { parseStackTrace, applySourceMapsToStack, enhanceErrorWithSourceMaps } from "../sourceMapUtils"
|
||||
|
||||
// Mock console.debug to avoid cluttering test output
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
describe("sourceMapUtils", () => {
|
||||
describe("parseStackTrace", () => {
|
||||
// Note: parseStackTrace is now a compatibility function
|
||||
test("should correctly parse a Chrome-style stack trace", async () => {
|
||||
const stackTrace = `Error: Test error
|
||||
at Function.execute (webpack:///./src/components/App.tsx:123:45)
|
||||
at Object.next (webpack:///./node_modules/react/index.js:76:21)
|
||||
at eval (webpack:///./src/utils/helpers.ts:89:10)`
|
||||
|
||||
const frames = await parseStackTrace(stackTrace)
|
||||
|
||||
// Verify it still returns an array of frame objects
|
||||
expect(frames).toBeInstanceOf(Array)
|
||||
expect(frames.length).toBeGreaterThan(0)
|
||||
|
||||
// Check that the first frame has the expected properties
|
||||
const firstFrame = frames[0]
|
||||
expect(firstFrame).toHaveProperty("functionName")
|
||||
expect(firstFrame).toHaveProperty("fileName")
|
||||
expect(firstFrame).toHaveProperty("lineNumber")
|
||||
expect(firstFrame).toHaveProperty("columnNumber")
|
||||
expect(firstFrame).toHaveProperty("source")
|
||||
|
||||
// Verify the first frame has the correct values
|
||||
expect(firstFrame.fileName).toBe("webpack:///./src/components/App.tsx")
|
||||
expect(firstFrame.lineNumber).toBe(123)
|
||||
expect(firstFrame.columnNumber).toBe(45)
|
||||
})
|
||||
|
||||
test("should return empty array for empty stack", async () => {
|
||||
expect(await parseStackTrace("")).toEqual([])
|
||||
expect(await parseStackTrace(undefined as unknown as string)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("applySourceMapsToStack", () => {
|
||||
test("should return original stack when source maps cannot be applied", async () => {
|
||||
const stackTrace = `Error: Test error
|
||||
at Function.execute (webpack:///./src/components/App.tsx:123:45)`
|
||||
|
||||
const result = await applySourceMapsToStack(stackTrace)
|
||||
|
||||
// For now, we expect it to return the original stack
|
||||
// since we haven't implemented actual source map application
|
||||
expect(result).toBe(stackTrace)
|
||||
})
|
||||
|
||||
test("should handle empty stack", async () => {
|
||||
const emptyStack = ""
|
||||
const result = await applySourceMapsToStack(emptyStack)
|
||||
expect(result).toBe(emptyStack)
|
||||
})
|
||||
})
|
||||
|
||||
describe("enhanceErrorWithSourceMaps", () => {
|
||||
test("should add sourceMappedStack property to error", async () => {
|
||||
const error = new Error("Test error")
|
||||
error.stack = `Error: Test error
|
||||
at Function.execute (webpack:///./src/components/App.tsx:123:45)`
|
||||
|
||||
// Mock the applySourceMapsToStack function
|
||||
vi.spyOn(global.console, "error").mockImplementation(() => {})
|
||||
|
||||
const enhancedError = await enhanceErrorWithSourceMaps(error)
|
||||
|
||||
expect(enhancedError).toBe(error) // Should return the same error object
|
||||
expect("sourceMappedStack" in enhancedError).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle errors without stack", async () => {
|
||||
const error = new Error("Test error")
|
||||
error.stack = undefined
|
||||
|
||||
const enhancedError = await enhanceErrorWithSourceMaps(error)
|
||||
|
||||
expect(enhancedError).toBe(error)
|
||||
expect("sourceMappedStack" in enhancedError).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
180
webview-ui/src/utils/sourceMapInitializer.ts
Normal file
180
webview-ui/src/utils/sourceMapInitializer.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* Source Map Initializer
|
||||
*
|
||||
* This utility ensures source maps are properly loaded in production builds.
|
||||
* It attempts to preload source maps for all scripts on the page and
|
||||
* sets up global error handlers to enhance errors with source maps.
|
||||
*
|
||||
* This implementation is compatible with VSCode's Content Security Policy.
|
||||
*/
|
||||
|
||||
import { enhanceErrorWithSourceMaps } from "./sourceMapUtils"
|
||||
|
||||
/**
|
||||
* Initialize source map support for production builds
|
||||
*/
|
||||
export function initializeSourceMaps(): void {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
// Only needed in production builds
|
||||
return
|
||||
}
|
||||
|
||||
console.debug("Initializing CSP-compatible source map support for production build")
|
||||
|
||||
// Set up global error handler
|
||||
window.addEventListener("error", async (event) => {
|
||||
if (event.error && event.error instanceof Error) {
|
||||
try {
|
||||
// Apply source maps to the error
|
||||
const enhancedError = await enhanceErrorWithSourceMaps(event.error)
|
||||
|
||||
// Log the enhanced error
|
||||
console.error("Source mapped error:", enhancedError)
|
||||
|
||||
// Don't prevent default handling - let the ErrorBoundary catch it
|
||||
} catch (e) {
|
||||
console.error("Error enhancing error with source maps:", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set up unhandled promise rejection handler
|
||||
window.addEventListener("unhandledrejection", async (event) => {
|
||||
if (event.reason && event.reason instanceof Error) {
|
||||
try {
|
||||
// Apply source maps to the error
|
||||
const enhancedError = await enhanceErrorWithSourceMaps(event.reason)
|
||||
|
||||
// Log the enhanced error
|
||||
console.error("Source mapped rejection:", enhancedError)
|
||||
} catch (e) {
|
||||
console.error("Error enhancing rejection with source maps:", e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Preload source maps for all scripts
|
||||
try {
|
||||
const scripts = document.getElementsByTagName("script")
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const script = scripts[i]
|
||||
if (script.src) {
|
||||
// Try multiple source map locations
|
||||
const possibleMapUrls = [
|
||||
`${script.src}.map`,
|
||||
`${script.src}?source-map=true`,
|
||||
script.src.replace(/\.js$/, ".js.map"),
|
||||
script.src.replace(/\.js$/, ".map.json"),
|
||||
script.src.replace(/\.js$/, ".sourcemap"),
|
||||
]
|
||||
|
||||
// Preload all possible source map locations
|
||||
for (const mapUrl of possibleMapUrls) {
|
||||
const link = document.createElement("link")
|
||||
link.rel = "preload"
|
||||
link.as = "fetch"
|
||||
link.href = mapUrl
|
||||
link.crossOrigin = "anonymous"
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
// Also check for inline sourceMappingURL comments
|
||||
fetch(script.src)
|
||||
.then((response) => response.text())
|
||||
.then((content) => {
|
||||
const sourceMappingURLMatch = content.match(/\/\/[#@]\s*sourceMappingURL=([^\s]+)/)
|
||||
if (sourceMappingURLMatch && sourceMappingURLMatch[1]) {
|
||||
const sourceMappingURL = sourceMappingURLMatch[1]
|
||||
|
||||
// If it's not a data: URL, preload it
|
||||
if (!sourceMappingURL.startsWith("data:")) {
|
||||
const scriptUrlObj = new URL(script.src)
|
||||
const baseUrl = scriptUrlObj.href.substring(0, scriptUrlObj.href.lastIndexOf("/") + 1)
|
||||
const fullUrl = new URL(sourceMappingURL, baseUrl).href
|
||||
|
||||
const link = document.createElement("link")
|
||||
link.rel = "preload"
|
||||
link.as = "fetch"
|
||||
link.href = fullUrl
|
||||
link.crossOrigin = "anonymous"
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => console.debug("Error checking for inline sourceMappingURL:", e))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error preloading source maps:", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose source maps on the window object for debugging
|
||||
*/
|
||||
export function exposeSourceMapsForDebugging(): void {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Add a global function to manually apply source maps to an error
|
||||
;(window as any).__applySourceMaps = async (error: Error) => {
|
||||
if (!(error instanceof Error)) {
|
||||
console.error("Not an Error object:", error)
|
||||
return error
|
||||
}
|
||||
return await enhanceErrorWithSourceMaps(error)
|
||||
}
|
||||
|
||||
// Add a global function to test source map functionality
|
||||
;(window as any).__testSourceMaps = () => {
|
||||
try {
|
||||
// Intentionally cause an error
|
||||
const obj: any = undefined
|
||||
obj.nonExistentMethod()
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
console.log("Original error:", e)
|
||||
;(window as any).__applySourceMaps(e).then((enhanced: Error) => {
|
||||
console.log("Enhanced error:", enhanced)
|
||||
|
||||
// Log the source mapped stack if available
|
||||
if ("sourceMappedStack" in enhanced) {
|
||||
console.log("Source mapped stack:", enhanced.sourceMappedStack)
|
||||
}
|
||||
|
||||
// Log the source mapped component stack if available
|
||||
if ("sourceMappedComponentStack" in enhanced) {
|
||||
console.log("Source mapped component stack:", enhanced.sourceMappedComponentStack)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add a global function to check if source maps are available for a script
|
||||
;(window as any).__checkSourceMap = async (scriptUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${scriptUrl}.map`)
|
||||
if (response.ok) {
|
||||
const sourceMap = await response.json()
|
||||
const originalFileName =
|
||||
sourceMap.sources && sourceMap.sources.length > 0 ? sourceMap.sources[0] : "unknown"
|
||||
console.log(`Source map found for ${scriptUrl}. Original file: ${originalFileName}`)
|
||||
return true
|
||||
} else {
|
||||
console.log(`No source map found for ${scriptUrl}`)
|
||||
return false
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error checking source map for ${scriptUrl}:`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
console.debug("Source map debugging utilities exposed on window object")
|
||||
} catch (e) {
|
||||
console.error("Error exposing source maps for debugging:", e)
|
||||
}
|
||||
}
|
||||
190
webview-ui/src/utils/sourceMapUtils.ts
Normal file
190
webview-ui/src/utils/sourceMapUtils.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import * as StackTrace from "stacktrace-js"
|
||||
|
||||
/**
|
||||
* Extended Error interface with source mapped stack trace
|
||||
*/
|
||||
export interface EnhancedError extends Error {
|
||||
sourceMappedStack?: string
|
||||
sourceMappedComponentStack?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply source maps to a stack trace using StackTrace.js
|
||||
* Returns the original stack trace if source maps can't be applied
|
||||
*/
|
||||
export async function applySourceMapsToStack(stack: string): Promise<string> {
|
||||
if (!stack) {
|
||||
console.debug("applySourceMapsToStack: Empty stack trace provided")
|
||||
return stack
|
||||
}
|
||||
|
||||
console.debug("Original stack trace:", stack)
|
||||
|
||||
try {
|
||||
// Create a temporary Error object with the provided stack
|
||||
const tempError = new Error()
|
||||
tempError.stack = stack
|
||||
|
||||
// Extract the error message (first line)
|
||||
const errorMessage = stack.split("\n")[0]
|
||||
console.debug("Error message:", errorMessage)
|
||||
|
||||
// Use StackTrace.js to get source mapped stack frames
|
||||
const stackFrames = await StackTrace.fromError(tempError)
|
||||
console.debug("StackTrace.js parsed frames:", stackFrames)
|
||||
|
||||
// Convert stack frames back to string format
|
||||
const mappedFrames = stackFrames.map((frame: StackTrace.StackFrame) => {
|
||||
const functionName = frame.functionName || "<anonymous>"
|
||||
const fileName = frame.fileName || "unknown"
|
||||
const lineNumber = frame.lineNumber || 0
|
||||
const columnNumber = frame.columnNumber || 0
|
||||
|
||||
return ` at ${functionName} (${fileName}:${lineNumber}:${columnNumber})`
|
||||
})
|
||||
|
||||
// Reconstruct the stack trace with the error message
|
||||
const result = [errorMessage, ...mappedFrames].join("\n")
|
||||
console.debug("Final mapped stack trace:", result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("Error applying source maps with StackTrace.js:", error)
|
||||
return stack // Return original stack on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply source maps to a React component stack trace using StackTrace.js
|
||||
*/
|
||||
export async function applySourceMapsToComponentStack(componentStack: string): Promise<string> {
|
||||
if (!componentStack) {
|
||||
console.debug("applySourceMapsToComponentStack: Empty component stack provided")
|
||||
return componentStack
|
||||
}
|
||||
|
||||
console.debug("Original component stack:", componentStack)
|
||||
|
||||
try {
|
||||
// Component stack has a different format than error stack
|
||||
// Example: at ComponentName (file:///path/to/file.tsx:123:45)
|
||||
const lines = componentStack.split("\n")
|
||||
const mappedLines = await Promise.all(
|
||||
lines.map(async (line) => {
|
||||
// Skip empty lines
|
||||
if (!line.trim()) return line
|
||||
|
||||
// Extract file path, line and column numbers
|
||||
const match = line.match(/at\s+(.+?)\s+\((.+?):(\d+):(\d+)\)/)
|
||||
if (!match) return line
|
||||
|
||||
const [_, componentName, fileName, lineNumber, columnNumber] = match
|
||||
console.debug(`Processing component stack line:`, { componentName, fileName, lineNumber, columnNumber })
|
||||
|
||||
try {
|
||||
// Create a synthetic stack frame for StackTrace.js
|
||||
const syntheticError = new Error()
|
||||
syntheticError.stack = `Error\n at ${componentName} (${fileName}:${lineNumber}:${columnNumber})`
|
||||
|
||||
// Use StackTrace.js to resolve source maps
|
||||
const stackFrames = await StackTrace.fromError(syntheticError)
|
||||
|
||||
if (stackFrames.length > 0) {
|
||||
const frame = stackFrames[0]
|
||||
const mappedFileName = frame.fileName || fileName
|
||||
const mappedLineNumber = frame.lineNumber || parseInt(lineNumber, 10)
|
||||
const mappedColumnNumber = frame.columnNumber || parseInt(columnNumber, 10)
|
||||
|
||||
return `at ${componentName} (${mappedFileName}:${mappedLineNumber}:${mappedColumnNumber})`
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug(`Error processing component stack line with StackTrace.js:`, e)
|
||||
}
|
||||
|
||||
return line
|
||||
}),
|
||||
)
|
||||
|
||||
const result = mappedLines.join("\n")
|
||||
console.debug("Final mapped component stack:", result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error("Error applying source maps to component stack with StackTrace.js:", error)
|
||||
return componentStack
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance an Error object with source mapped stack trace and component stack
|
||||
*/
|
||||
export function enhanceErrorWithSourceMaps(error: Error, componentStack?: string): Promise<EnhancedError> {
|
||||
console.debug("Enhancing error with source maps using StackTrace.js:", error)
|
||||
|
||||
return new Promise<EnhancedError>((resolve) => {
|
||||
if (!error.stack) {
|
||||
console.debug("Error has no stack trace")
|
||||
resolve(error as EnhancedError)
|
||||
return
|
||||
}
|
||||
|
||||
// Process both stacks in parallel
|
||||
const stackPromise = applySourceMapsToStack(error.stack)
|
||||
const componentStackPromise = componentStack
|
||||
? applySourceMapsToComponentStack(componentStack)
|
||||
: Promise.resolve(undefined)
|
||||
|
||||
Promise.all([stackPromise, componentStackPromise])
|
||||
.then(([sourceMappedStack, sourceMappedComponentStack]) => {
|
||||
console.debug("Source mapped stacks applied successfully with StackTrace.js")
|
||||
|
||||
// Extend the error object with the source mapped stack
|
||||
Object.defineProperty(error, "sourceMappedStack", {
|
||||
value: sourceMappedStack,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
// Add the source mapped component stack if available
|
||||
if (sourceMappedComponentStack) {
|
||||
Object.defineProperty(error, "sourceMappedComponentStack", {
|
||||
value: sourceMappedComponentStack,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
resolve(error)
|
||||
})
|
||||
.catch((mapError) => {
|
||||
console.error("Error applying source maps with StackTrace.js:", mapError)
|
||||
// If anything fails, just return the original error
|
||||
resolve(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stack trace string into structured stack frames
|
||||
* This is kept for backward compatibility with tests
|
||||
*/
|
||||
export async function parseStackTrace(stack: string): Promise<any[]> {
|
||||
if (!stack) return []
|
||||
|
||||
try {
|
||||
// Create a temporary Error object with the provided stack
|
||||
const tempError = new Error()
|
||||
tempError.stack = stack
|
||||
|
||||
// Use StackTrace.js to parse the stack
|
||||
const frames = await StackTrace.fromError(tempError)
|
||||
return frames.map((frame: StackTrace.StackFrame) => ({
|
||||
functionName: frame.functionName || "<anonymous>",
|
||||
fileName: frame.fileName,
|
||||
lineNumber: frame.lineNumber,
|
||||
columnNumber: frame.columnNumber,
|
||||
source: `at ${frame.functionName || "<anonymous>"} (${frame.fileName}:${frame.lineNumber}:${frame.columnNumber})`,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Error parsing stack trace with StackTrace.js:", error)
|
||||
return [] // Return empty array if parsing fails
|
||||
}
|
||||
}
|
||||
116
webview-ui/src/vite-plugins/sourcemapPlugin.ts
Normal file
116
webview-ui/src/vite-plugins/sourcemapPlugin.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { Plugin } from "vite"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Custom Vite plugin to ensure source maps are properly included in the build
|
||||
* This plugin copies source maps to the build directory and ensures they're accessible
|
||||
*/
|
||||
export function sourcemapPlugin(): Plugin {
|
||||
return {
|
||||
name: "vite-plugin-sourcemap",
|
||||
apply: "build",
|
||||
|
||||
// After the build is complete, ensure source maps are included in the build
|
||||
closeBundle: {
|
||||
order: "post",
|
||||
handler: async () => {
|
||||
console.log("Ensuring source maps are included in build...")
|
||||
|
||||
// Determine the correct output directory based on the build mode
|
||||
const mode = process.env.NODE_ENV
|
||||
let outDir
|
||||
|
||||
if (mode === "nightly") {
|
||||
outDir = path.resolve("../apps/vscode-nightly/build/webview-ui/build")
|
||||
} else {
|
||||
outDir = path.resolve("../src/webview-ui/build")
|
||||
}
|
||||
|
||||
const assetsDir = path.join(outDir, "assets")
|
||||
|
||||
console.log(`Source map processing for ${mode} build in ${outDir}`)
|
||||
|
||||
// Check if build directory exists
|
||||
if (!fs.existsSync(outDir)) {
|
||||
console.warn("Build directory not found:", outDir)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if assets directory exists
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
console.warn("Assets directory not found:", assetsDir)
|
||||
return
|
||||
}
|
||||
|
||||
// Find JS files in the assets directory
|
||||
const jsFiles = fs.readdirSync(assetsDir).filter((file) => file.endsWith(".js"))
|
||||
|
||||
console.log(`Found ${jsFiles.length} JS files in assets directory`)
|
||||
|
||||
// Check for source maps
|
||||
for (const jsFile of jsFiles) {
|
||||
const jsPath = path.join(assetsDir, jsFile)
|
||||
const mapPath = jsPath + ".map"
|
||||
|
||||
// If source map exists, ensure it's properly referenced in the JS file
|
||||
if (fs.existsSync(mapPath)) {
|
||||
console.log(`Source map found for ${jsFile}`)
|
||||
|
||||
// Read the JS file
|
||||
let jsContent = fs.readFileSync(jsPath, "utf8")
|
||||
|
||||
// Check if the source map is already referenced
|
||||
if (!jsContent.includes("//# sourceMappingURL=")) {
|
||||
console.log(`Adding source map reference to ${jsFile}`)
|
||||
|
||||
// Add source map reference
|
||||
jsContent += `\n//# sourceMappingURL=${jsFile}.map\n`
|
||||
|
||||
// Write the updated JS file
|
||||
fs.writeFileSync(jsPath, jsContent)
|
||||
}
|
||||
|
||||
// Make sure map file is in the correct format and has proper sourceRoot
|
||||
try {
|
||||
const mapContent = JSON.parse(fs.readFileSync(mapPath, "utf8"))
|
||||
|
||||
// Ensure the sourceRoot is set correctly for VSCode webview
|
||||
if (!mapContent.sourceRoot) {
|
||||
mapContent.sourceRoot = ""
|
||||
}
|
||||
|
||||
// Make sure "sources" paths are relative
|
||||
if (mapContent.sources) {
|
||||
mapContent.sources = mapContent.sources.map((source: string) => {
|
||||
// Remove absolute paths to ensure they work in VSCode webview context
|
||||
return source.replace(/^\//, "")
|
||||
})
|
||||
}
|
||||
|
||||
// Write back the updated source map
|
||||
fs.writeFileSync(mapPath, JSON.stringify(mapContent))
|
||||
console.log(`Updated source map for ${jsFile}`)
|
||||
} catch (error) {
|
||||
console.error(`Error processing source map for ${jsFile}:`, error)
|
||||
}
|
||||
} else {
|
||||
console.log(`No source map found for ${jsFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a special file to enable source map loading in production
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "sourcemap-manifest.json"),
|
||||
JSON.stringify({
|
||||
enabled: true,
|
||||
version: process.env.PKG_VERSION || "unknown",
|
||||
buildTime: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
|
||||
console.log("Source map processing complete")
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { execSync } from "child_process"
|
|||
import { defineConfig, type PluginOption, type Plugin } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { sourcemapPlugin } from "./src/vite-plugins/sourcemapPlugin"
|
||||
|
||||
function getGitSha() {
|
||||
let gitSha: string | undefined = undefined
|
||||
|
|
@ -79,7 +80,7 @@ export default defineConfig(({ mode }) => {
|
|||
define["process.env.PKG_OUTPUT_CHANNEL"] = JSON.stringify("Roo-Code-Nightly")
|
||||
}
|
||||
|
||||
const plugins: PluginOption[] = [react(), tailwindcss(), persistPortPlugin(), wasmPlugin()]
|
||||
const plugins: PluginOption[] = [react(), tailwindcss(), persistPortPlugin(), wasmPlugin(), sourcemapPlugin()]
|
||||
|
||||
return {
|
||||
plugins,
|
||||
|
|
@ -94,7 +95,10 @@ export default defineConfig(({ mode }) => {
|
|||
outDir,
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: false,
|
||||
// Generate complete source maps with original TypeScript sources
|
||||
sourcemap: true,
|
||||
// Ensure source maps are properly included in the build
|
||||
minify: mode === "production" ? "esbuild" : false,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: `assets/[name].js`,
|
||||
|
|
@ -114,6 +118,10 @@ export default defineConfig(({ mode }) => {
|
|||
) {
|
||||
return "assets/fonts/[name][extname]"
|
||||
}
|
||||
// Ensure source maps are included in the build
|
||||
if (assetInfo.name && assetInfo.name.endsWith(".map")) {
|
||||
return "assets/[name]"
|
||||
}
|
||||
return "assets/[name][extname]"
|
||||
},
|
||||
manualChunks: (id, { getModuleInfo }) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue