Rough first version of bug reporting

This commit is contained in:
Matt Rubens 2025-03-21 01:21:28 -04:00
parent 95ba760daf
commit 37307d56a1
8 changed files with 623 additions and 2 deletions

View file

@ -2020,6 +2020,24 @@ export class ClineProvider extends EventEmitter<ClineProviderEvents> implements
await this.postStateToWebview()
break
}
case "getBugReportInfo": {
// Send environment information back to the webview for bug reporting
const properties = await this.getTelemetryProperties()
await this.postMessageToWebview({
type: "bugReportInfo",
info: properties,
})
break
}
case "openExternal": {
// Open an external URL (used for GitHub issue creation)
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
}
}
},
null,

View file

@ -58,6 +58,7 @@ export interface ExtensionMessage {
| "ttsStop"
| "maxReadFileLine"
| "fileSearchResults"
| "bugReportInfo"
text?: string
action?:
| "chatButtonClicked"
@ -100,6 +101,7 @@ export interface ExtensionMessage {
label?: string
}>
error?: string
info?: Record<string, any> // Environment information for bug reports
}
export interface ApiConfigMeta {

View file

@ -116,6 +116,8 @@ export interface WebviewMessage {
| "language"
| "maxReadFileLine"
| "searchFiles"
| "getBugReportInfo"
| "openExternal"
text?: string
disabled?: boolean
askResponse?: ClineAskResponse
@ -141,6 +143,7 @@ export interface WebviewMessage {
source?: "global" | "project"
requestId?: string
ids?: string[]
url?: string // URL for openExternal message
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -1,7 +1,7 @@
import { HTMLAttributes } from "react"
import { HTMLAttributes, useState } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import { Info } from "lucide-react"
import { Bug, Info } from "lucide-react"
import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
@ -11,6 +11,7 @@ import { vscode } from "@/utils/vscode"
import { cn } from "@/lib/utils"
import { SectionHeader } from "./SectionHeader"
import { BugReportDialog } from "./BugReportDialog"
import { Section } from "./Section"
type AboutProps = HTMLAttributes<HTMLDivElement> & {
@ -21,6 +22,7 @@ type AboutProps = HTMLAttributes<HTMLDivElement> & {
export const About = ({ version, telemetrySetting, setTelemetrySetting, className, ...props }: AboutProps) => {
const { t } = useAppTranslation()
const [showBugReportDialog, setShowBugReportDialog] = useState(false)
return (
<div className={cn("flex flex-col gap-2", className)} {...props}>
@ -73,7 +75,20 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam
{t("settings:footer.reset.button")}
</VSCodeButton>
</div>
<div className="flex justify-between items-center gap-3">
<p>{t("settings:footer.bugreport.description")}</p>
<VSCodeButton
onClick={() => setShowBugReportDialog(true)}
appearance="secondary"
className="shrink-0">
<Bug className="w-4 h-4 text-vscode-foreground mr-1" />
{t("settings:footer.bugreport.button")}
</VSCodeButton>
</div>
</Section>
{showBugReportDialog && <BugReportDialog onClose={() => setShowBugReportDialog(false)} />}
</div>
)
}

View file

@ -0,0 +1,150 @@
import { useEffect, useState } from "react"
import { Bug, ClipboardCopy, ExternalLink } from "lucide-react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { vscode } from "@/utils/vscode"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui"
interface BugReportDialogProps {
onClose: () => void
}
export function BugReportDialog({ onClose }: BugReportDialogProps) {
const { t } = useAppTranslation()
const [environmentInfo, setEnvironmentInfo] = useState<Record<string, any> | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
// Request environment info from extension
vscode.postMessage({ type: "getBugReportInfo" })
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "bugReportInfo" && message.info) {
setEnvironmentInfo(message.info)
}
}
window.addEventListener("message", handleMessage)
return () => {
window.removeEventListener("message", handleMessage)
}
}, [])
const formattedInfo = environmentInfo
? Object.entries(environmentInfo)
.map(([key, value]) => `- **${key}**: ${value}`)
.join("\n")
: ""
const handleCopy = () => {
if (formattedInfo) {
navigator.clipboard.writeText(formattedInfo)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
const handleCreateIssue = () => {
if (!environmentInfo) return
// Build URL parameters to pre-fill GitHub issue template fields
const params = new URLSearchParams()
// Add parameters based on the template's field IDs
// version - The app version
if (environmentInfo.appVersion) {
params.append("version", environmentInfo.appVersion)
}
// provider - Try to extract from API provider if available
if (environmentInfo.apiProvider) {
params.append("provider", environmentInfo.apiProvider)
}
// model - Try to extract from model ID if available
if (environmentInfo.modelId) {
params.append("model", environmentInfo.modelId)
}
// what-happened - Pre-fill with a template that includes environment info
const environmentSummary = Object.entries(environmentInfo)
.map(([key, value]) => `${key}: ${value}`)
.join("\n")
params.append(
"what-happened",
`I encountered an issue with Roo Code.\n\nEnvironment Information:\n${environmentSummary}`,
)
// additional-context - Add any system-specific details
if (environmentInfo.platform) {
params.append(
"additional-context",
`Platform: ${environmentInfo.platform}\nVSCode: ${environmentInfo.vscodeVersion || "unknown"}`,
)
}
const issueUrl = `https://github.com/RooVetGit/Roo-Code/issues/new?template=bug_report.yml&${params.toString()}`
// Open URL
vscode.postMessage({ type: "openExternal", url: issueUrl })
onClose()
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bug className="w-5 h-5" />
{t("settings:bugreport.title")}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="mb-4">{t("settings:bugreport.description")}</p>
<div className="bg-vscode-editor-background rounded-md p-3 text-sm font-mono overflow-auto max-h-[300px] whitespace-pre-wrap">
{environmentInfo ? (
<pre>{formattedInfo}</pre>
) : (
<div className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-3 py-1">
<div className="h-2 bg-vscode-editor-inactiveSelectionBackground rounded"></div>
<div className="h-2 bg-vscode-editor-inactiveSelectionBackground rounded"></div>
<div className="h-2 bg-vscode-editor-inactiveSelectionBackground rounded"></div>
</div>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-row justify-between items-center gap-2">
<VSCodeButton
appearance="secondary"
onClick={handleCopy}
disabled={!environmentInfo}
className="flex items-center gap-1">
<ClipboardCopy className="w-4 h-4 mr-1" />
{copied ? t("settings:bugreport.copied") : t("settings:bugreport.copy")}
</VSCodeButton>
<div className="flex gap-2">
<VSCodeButton appearance="secondary" onClick={onClose}>
{t("settings:common.cancel")}
</VSCodeButton>
<VSCodeButton
appearance="primary"
onClick={handleCreateIssue}
disabled={!environmentInfo}
className="flex items-center gap-1">
<ExternalLink className="w-4 h-4 mr-1" />
{t("settings:bugreport.createIssue")}
</VSCodeButton>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,137 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { About } from "../About"
import { TranslationContext } from "@/i18n/TranslationContext"
import { TelemetrySetting } from "../../../../../src/shared/TelemetrySetting"
// Mock the BugReportDialog component
jest.mock("../BugReportDialog", () => ({
BugReportDialog: ({ onClose }: { onClose: () => void }) => (
<div data-testid="bug-report-dialog">
<button data-testid="close-dialog" onClick={onClose}>
Close
</button>
</div>
),
}))
// Mock lucide-react icons
jest.mock("lucide-react", () => ({
Info: () => <div data-testid="info-icon" />,
Bug: () => <div data-testid="bug-icon" />,
}))
// Mock VSCode components
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, appearance, className }: any) => (
<button onClick={onClick} data-appearance={appearance} className={className} data-testid="vscode-button">
{children}
</button>
),
VSCodeCheckbox: ({ children, onChange, checked }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
data-testid="telemetry-checkbox"
/>
{children}
</label>
),
VSCodeLink: ({ children, href }: any) => <a href={href}>{children}</a>,
}))
// Mock vscode API
jest.mock("@/utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
const mockT = jest.fn((key) => key)
const mockTranslationContext = {
t: mockT,
i18n: {} as any,
}
describe("About component", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderComponent = (
props: {
version?: string
telemetrySetting?: TelemetrySetting
setTelemetrySetting?: (setting: TelemetrySetting) => void
} = {},
) => {
const { version = "1.0.0", telemetrySetting = "unset", setTelemetrySetting = jest.fn() } = props
return render(
<TranslationContext.Provider value={mockTranslationContext}>
<About
version={version}
telemetrySetting={telemetrySetting}
setTelemetrySetting={setTelemetrySetting}
/>
</TranslationContext.Provider>,
)
}
it("renders the version number", () => {
renderComponent({ version: "1.2.3" })
expect(mockT).toHaveBeenCalledWith("settings:sections.about")
expect(screen.getByText(/Version: 1.2.3/i)).toBeInTheDocument()
})
it("renders the telemetry checkbox", () => {
renderComponent()
expect(screen.getByTestId("telemetry-checkbox")).toBeInTheDocument()
})
it("renders the bug report button", () => {
renderComponent()
const bugReportText = mockT("settings:footer.bugreport.button")
expect(screen.getByText(bugReportText)).toBeInTheDocument()
expect(screen.getByTestId("bug-icon")).toBeInTheDocument()
})
it("opens the bug report dialog when clicking the button", async () => {
renderComponent()
// Bug report dialog shouldn't be visible initially
expect(screen.queryByTestId("bug-report-dialog")).not.toBeInTheDocument()
// Click the report bug button
const bugReportButton = screen.getByText(mockT("settings:footer.bugreport.button"))
fireEvent.click(bugReportButton)
// Dialog should now be visible
await waitFor(() => {
expect(screen.getByTestId("bug-report-dialog")).toBeInTheDocument()
})
})
it("closes the bug report dialog", async () => {
renderComponent()
// Open dialog
const bugReportButton = screen.getByText(mockT("settings:footer.bugreport.button"))
fireEvent.click(bugReportButton)
// Dialog should be visible
await waitFor(() => {
expect(screen.getByTestId("bug-report-dialog")).toBeInTheDocument()
})
// Close dialog
const closeButton = screen.getByTestId("close-dialog")
fireEvent.click(closeButton)
// Dialog should be hidden
await waitFor(() => {
expect(screen.queryByTestId("bug-report-dialog")).not.toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,285 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
import { BugReportDialog } from "../BugReportDialog"
import { TranslationContext } from "@/i18n/TranslationContext"
// Mock vscode API
const mockPostMessage = jest.fn()
jest.mock("@/utils/vscode", () => ({
vscode: {
postMessage: mockPostMessage,
},
}))
// Mock window.addEventListener to capture message handler
type MessageHandler = (event: any) => void
const mockMessageHandlers: Record<string, MessageHandler> = {}
const originalAddEventListener = window.addEventListener
const originalRemoveEventListener = window.removeEventListener
beforeAll(() => {
window.addEventListener = jest.fn((event, handler) => {
if (event === "message" && typeof handler === "function") {
mockMessageHandlers[event] = handler as MessageHandler
}
return originalAddEventListener(event, handler)
})
window.removeEventListener = jest.fn((event, handler) => {
if (event === "message" && typeof handler === "function" && mockMessageHandlers[event] === handler) {
delete mockMessageHandlers[event]
}
return originalRemoveEventListener(event, handler)
})
})
afterAll(() => {
window.addEventListener = originalAddEventListener
window.removeEventListener = originalRemoveEventListener
})
// Mock lucide-react icons
jest.mock("lucide-react", () => ({
Bug: () => <div data-testid="bug-icon" />,
ClipboardCopy: () => <div data-testid="clipboard-icon" />,
ExternalLink: () => <div data-testid="external-link-icon" />,
}))
// Mock Dialog components
jest.mock("@/components/ui", () => ({
Dialog: ({ children, open, onOpenChange }: any) => (
<div data-testid="dialog" data-open={open} onClick={() => onOpenChange && onOpenChange(false)}>
{children}
</div>
),
DialogContent: ({ children, className }: any) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: any) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogTitle: ({ children, className }: any) => (
<div data-testid="dialog-title" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: any) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
}))
// Mock VSCode components
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeButton: ({ children, onClick, appearance, disabled, className }: any) => (
<button
onClick={onClick}
data-appearance={appearance}
disabled={disabled}
className={className}
data-testid={
appearance === "primary"
? "create-issue-button"
: appearance === "secondary"
? "copy-button"
: "default-button"
}>
{children}
</button>
),
}))
// Mock clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
})
const mockT = jest.fn((key) => key)
const mockTranslationContext = {
t: mockT,
i18n: {} as any,
}
describe("BugReportDialog component", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderComponent = (props: { onClose?: () => void } = {}) => {
const { onClose = jest.fn() } = props
return render(
<TranslationContext.Provider value={mockTranslationContext}>
<BugReportDialog onClose={onClose} />
</TranslationContext.Provider>,
)
}
it("should render the dialog", () => {
renderComponent()
expect(screen.getByTestId("dialog")).toBeInTheDocument()
expect(screen.getByTestId("dialog-content")).toBeInTheDocument()
expect(screen.getByTestId("bug-icon")).toBeInTheDocument()
})
it("should request environment info when mounted", () => {
renderComponent()
expect(mockPostMessage).toHaveBeenCalledWith({ type: "getBugReportInfo" })
})
it("should display loading state initially", () => {
renderComponent()
expect(screen.getByTestId("create-issue-button")).toBeDisabled()
expect(screen.getByTestId("copy-button")).toBeDisabled()
expect(screen.getByTestId("dialog-content").textContent).toContain(mockT("settings:bugreport.description"))
})
it("should display environment info when received", async () => {
renderComponent()
// Simulate receiving environment info
const envInfo = {
vscodeVersion: "1.70.0",
platform: "darwin",
appVersion: "1.2.3",
}
// Send a message with environment info
if (mockMessageHandlers.message) {
mockMessageHandlers.message({
data: {
type: "bugReportInfo",
info: envInfo,
},
})
}
// Should display the formatted info
await waitFor(() => {
const content = screen.getByTestId("dialog-content")
expect(content.textContent).toContain("vscodeVersion")
expect(content.textContent).toContain("platform")
expect(content.textContent).toContain("appVersion")
})
// Buttons should be enabled now
expect(screen.getByTestId("create-issue-button")).not.toBeDisabled()
expect(screen.getByTestId("copy-button")).not.toBeDisabled()
})
it("should copy environment info to clipboard", async () => {
renderComponent()
// Simulate receiving environment info
const envInfo = {
vscodeVersion: "1.70.0",
platform: "darwin",
appVersion: "1.2.3",
}
// Send a message with environment info
if (mockMessageHandlers.message) {
mockMessageHandlers.message({
data: {
type: "bugReportInfo",
info: envInfo,
},
})
}
// Wait for info to be displayed
await waitFor(() => {
expect(screen.getByTestId("copy-button")).not.toBeDisabled()
})
// Click copy button
fireEvent.click(screen.getByTestId("copy-button"))
// Should call clipboard API
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
expect.stringContaining("- **vscodeVersion**: 1.70.0"),
)
})
it("should create GitHub issue when button is clicked", async () => {
const onClose = jest.fn()
renderComponent({ onClose })
// Simulate receiving environment info
const envInfo = {
vscodeVersion: "1.70.0",
platform: "darwin",
appVersion: "1.2.3",
}
// Send a message with environment info
if (mockMessageHandlers.message) {
mockMessageHandlers.message({
data: {
type: "bugReportInfo",
info: envInfo,
},
})
}
// Wait for info to be displayed
await waitFor(() => {
expect(screen.getByTestId("create-issue-button")).not.toBeDisabled()
})
// Click create issue button
fireEvent.click(screen.getByTestId("create-issue-button"))
// Should post message to VS Code with URL containing environment info
expect(mockPostMessage).toHaveBeenCalledWith({
type: "openExternal",
url: expect.stringContaining("https://github.com/RooVetGit/Roo-Code/issues/new?template=bug_report.yml"),
})
// Verify URL contains the required parameters
const urlArg = mockPostMessage.mock.calls[mockPostMessage.mock.calls.length - 1][0].url
// Check for version parameter
expect(urlArg).toContain("version=1.2.3")
// Check for what-happened parameter with environment info
expect(urlArg).toContain("what-happened=")
expect(urlArg).toContain("Environment+Information")
expect(urlArg).toContain("vscodeVersion")
expect(urlArg).toContain("platform")
expect(urlArg).toContain("appVersion")
// Check for additional-context parameter
expect(urlArg).toContain("additional-context=")
expect(urlArg).toContain("Platform%3A+darwin")
// Should call onClose
expect(onClose).toHaveBeenCalled()
})
it("should close when cancel button is clicked", () => {
const onClose = jest.fn()
renderComponent({ onClose })
// Find the cancel button (secondary button that isn't the copy button)
const buttons = screen.getAllByRole("button")
const cancelButton = buttons.find(
(button) =>
button !== screen.getByTestId("copy-button") && button !== screen.getByTestId("create-issue-button"),
)
// Click cancel button
if (cancelButton) {
fireEvent.click(cancelButton)
expect(onClose).toHaveBeenCalled()
} else {
fail("Cancel button not found")
}
})
})

View file

@ -372,6 +372,10 @@
"reset": {
"description": "Reset all global state and secret storage in the extension.",
"button": "Reset"
},
"bugreport": {
"description": "Found a bug? Report it to help us improve Roo Code.",
"button": "Report Bug"
}
},
"thinkingBudget": {
@ -423,5 +427,12 @@
"labels": {
"customArn": "Custom ARN",
"useCustomArn": "Use custom ARN..."
},
"bugreport": {
"title": "Report a Bug",
"description": "The following information will be included in your bug report to help us diagnose the issue:",
"copy": "Copy Info",
"copied": "Copied!",
"createIssue": "Create GitHub Issue"
}
}