Skip to content

Commit bb358fb

Browse files
brunobergherroomote[bot]daniel-lxscte
authored
ux: add downloadable error diagnostics from chat errors (#10188)
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com> Co-authored-by: Chris Estreich <cestreich@gmail.com>
1 parent 6e2b852 commit bb358fb

File tree

25 files changed

+525
-30
lines changed

25 files changed

+525
-30
lines changed
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// npx vitest src/core/webview/__tests__/diagnosticsHandler.spec.ts
2+
3+
import * as path from "path"
4+
5+
// Mock vscode first
6+
vi.mock("vscode", () => {
7+
const showErrorMessage = vi.fn()
8+
const openTextDocument = vi.fn().mockResolvedValue({})
9+
const showTextDocument = vi.fn().mockResolvedValue(undefined)
10+
11+
return {
12+
window: {
13+
showErrorMessage,
14+
showTextDocument,
15+
},
16+
workspace: {
17+
openTextDocument,
18+
},
19+
}
20+
})
21+
22+
// Mock storage utilities
23+
vi.mock("../../../utils/storage", () => ({
24+
getTaskDirectoryPath: vi.fn(async () => "/mock/task-dir"),
25+
}))
26+
27+
// Mock fs utilities
28+
vi.mock("../../../utils/fs", () => ({
29+
fileExistsAtPath: vi.fn(),
30+
}))
31+
32+
// Mock fs/promises
33+
vi.mock("fs/promises", () => {
34+
const mockReadFile = vi.fn()
35+
const mockWriteFile = vi.fn().mockResolvedValue(undefined)
36+
37+
return {
38+
default: {
39+
readFile: mockReadFile,
40+
writeFile: mockWriteFile,
41+
},
42+
readFile: mockReadFile,
43+
writeFile: mockWriteFile,
44+
}
45+
})
46+
47+
import * as vscode from "vscode"
48+
import * as fs from "fs/promises"
49+
import * as fsUtils from "../../../utils/fs"
50+
import { generateErrorDiagnostics } from "../diagnosticsHandler"
51+
52+
describe("generateErrorDiagnostics", () => {
53+
const mockLog = vi.fn()
54+
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
})
58+
59+
it("generates a diagnostics file with error metadata and history", async () => {
60+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true as any)
61+
vi.mocked(fs.readFile).mockResolvedValue('[{"role": "user", "content": "test"}]' as any)
62+
63+
const result = await generateErrorDiagnostics({
64+
taskId: "test-task-id",
65+
globalStoragePath: "/mock/global/storage",
66+
values: {
67+
timestamp: "2025-01-01T00:00:00.000Z",
68+
version: "1.2.3",
69+
provider: "test-provider",
70+
model: "test-model",
71+
details: "Sample error details",
72+
},
73+
log: mockLog,
74+
})
75+
76+
expect(result.success).toBe(true)
77+
expect(result.filePath).toContain("roo-diagnostics-")
78+
79+
// Verify we attempted to read API history
80+
expect(fs.readFile).toHaveBeenCalledWith(path.join("/mock/task-dir", "api_conversation_history.json"), "utf8")
81+
82+
// Verify we wrote a diagnostics file with the expected content
83+
expect(fs.writeFile).toHaveBeenCalledTimes(1)
84+
const [writtenPath, writtenContent] = vi.mocked(fs.writeFile).mock.calls[0]
85+
// taskId.slice(0, 8) = "test-tas" from "test-task-id"
86+
expect(String(writtenPath)).toContain("roo-diagnostics-test-tas")
87+
expect(String(writtenContent)).toContain(
88+
"// Please share this file with Roo Code Support (support@roocode.com) to diagnose the issue faster",
89+
)
90+
expect(String(writtenContent)).toContain('"error":')
91+
expect(String(writtenContent)).toContain('"history":')
92+
expect(String(writtenContent)).toContain('"version": "1.2.3"')
93+
expect(String(writtenContent)).toContain('"provider": "test-provider"')
94+
expect(String(writtenContent)).toContain('"model": "test-model"')
95+
expect(String(writtenContent)).toContain('"details": "Sample error details"')
96+
97+
// Verify VS Code APIs were used to open the generated file
98+
expect(vscode.workspace.openTextDocument).toHaveBeenCalledTimes(1)
99+
expect(vscode.window.showTextDocument).toHaveBeenCalledTimes(1)
100+
})
101+
102+
it("uses empty history when API history file does not exist", async () => {
103+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false as any)
104+
105+
const result = await generateErrorDiagnostics({
106+
taskId: "test-task-id",
107+
globalStoragePath: "/mock/global/storage",
108+
values: {
109+
timestamp: "2025-01-01T00:00:00.000Z",
110+
version: "1.0.0",
111+
provider: "test",
112+
model: "test",
113+
details: "error",
114+
},
115+
log: mockLog,
116+
})
117+
118+
expect(result.success).toBe(true)
119+
120+
// Should not attempt to read file when it doesn't exist
121+
expect(fs.readFile).not.toHaveBeenCalled()
122+
123+
// Verify empty history in output
124+
const [, writtenContent] = vi.mocked(fs.writeFile).mock.calls[0]
125+
expect(String(writtenContent)).toContain('"history": []')
126+
})
127+
128+
it("uses default values when values are not provided", async () => {
129+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false as any)
130+
131+
const result = await generateErrorDiagnostics({
132+
taskId: "test-task-id",
133+
globalStoragePath: "/mock/global/storage",
134+
log: mockLog,
135+
})
136+
137+
expect(result.success).toBe(true)
138+
139+
// Verify defaults in output
140+
const [, writtenContent] = vi.mocked(fs.writeFile).mock.calls[0]
141+
expect(String(writtenContent)).toContain('"version": ""')
142+
expect(String(writtenContent)).toContain('"provider": ""')
143+
expect(String(writtenContent)).toContain('"model": ""')
144+
expect(String(writtenContent)).toContain('"details": ""')
145+
})
146+
147+
it("handles JSON parse error gracefully", async () => {
148+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(true as any)
149+
vi.mocked(fs.readFile).mockResolvedValue("invalid json" as any)
150+
151+
const result = await generateErrorDiagnostics({
152+
taskId: "test-task-id",
153+
globalStoragePath: "/mock/global/storage",
154+
values: {
155+
timestamp: "2025-01-01T00:00:00.000Z",
156+
version: "1.0.0",
157+
provider: "test",
158+
model: "test",
159+
details: "error",
160+
},
161+
log: mockLog,
162+
})
163+
164+
// Should still succeed but with empty history
165+
expect(result.success).toBe(true)
166+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to parse api_conversation_history.json")
167+
168+
// Verify empty history in output
169+
const [, writtenContent] = vi.mocked(fs.writeFile).mock.calls[0]
170+
expect(String(writtenContent)).toContain('"history": []')
171+
})
172+
173+
it("returns error result when file write fails", async () => {
174+
vi.mocked(fsUtils.fileExistsAtPath).mockResolvedValue(false as any)
175+
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write failed"))
176+
177+
const result = await generateErrorDiagnostics({
178+
taskId: "test-task-id",
179+
globalStoragePath: "/mock/global/storage",
180+
log: mockLog,
181+
})
182+
183+
expect(result.success).toBe(false)
184+
expect(result.error).toBe("Write failed")
185+
expect(mockLog).toHaveBeenCalledWith("Error generating diagnostics: Write failed")
186+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to generate diagnostics: Write failed")
187+
})
188+
})

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

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { Mock } from "vitest"
55
// Mock dependencies - must come before imports
66
vi.mock("../../../api/providers/fetchers/modelCache")
77

8+
// Mock the diagnosticsHandler module
9+
vi.mock("../diagnosticsHandler", () => ({
10+
generateErrorDiagnostics: vi.fn().mockResolvedValue({ success: true, filePath: "/tmp/diagnostics.json" }),
11+
}))
12+
813
import { webviewMessageHandler } from "../webviewMessageHandler"
914
import type { ClineProvider } from "../ClineProvider"
1015
import { getModels } from "../../../api/providers/fetchers/modelCache"
@@ -41,15 +46,24 @@ const mockClineProvider = {
4146

4247
import { t } from "../../../i18n"
4348

44-
vi.mock("vscode", () => ({
45-
window: {
46-
showInformationMessage: vi.fn(),
47-
showErrorMessage: vi.fn(),
48-
},
49-
workspace: {
50-
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
51-
},
52-
}))
49+
vi.mock("vscode", () => {
50+
const showInformationMessage = vi.fn()
51+
const showErrorMessage = vi.fn()
52+
const openTextDocument = vi.fn().mockResolvedValue({})
53+
const showTextDocument = vi.fn().mockResolvedValue(undefined)
54+
55+
return {
56+
window: {
57+
showInformationMessage,
58+
showErrorMessage,
59+
showTextDocument,
60+
},
61+
workspace: {
62+
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
63+
openTextDocument,
64+
},
65+
}
66+
})
5367

5468
vi.mock("../../../i18n", () => ({
5569
t: vi.fn((key: string, args?: Record<string, any>) => {
@@ -72,14 +86,20 @@ vi.mock("../../../i18n", () => ({
7286
vi.mock("fs/promises", () => {
7387
const mockRm = vi.fn().mockResolvedValue(undefined)
7488
const mockMkdir = vi.fn().mockResolvedValue(undefined)
89+
const mockReadFile = vi.fn().mockResolvedValue("[]")
90+
const mockWriteFile = vi.fn().mockResolvedValue(undefined)
7591

7692
return {
7793
default: {
7894
rm: mockRm,
7995
mkdir: mockMkdir,
96+
readFile: mockReadFile,
97+
writeFile: mockWriteFile,
8098
},
8199
rm: mockRm,
82100
mkdir: mockMkdir,
101+
readFile: mockReadFile,
102+
writeFile: mockWriteFile,
83103
}
84104
})
85105

@@ -90,6 +110,7 @@ import * as path from "path"
90110
import * as fsUtils from "../../../utils/fs"
91111
import { getWorkspacePath } from "../../../utils/path"
92112
import { ensureSettingsDirectoryExists } from "../../../utils/globalContext"
113+
import { generateErrorDiagnostics } from "../diagnosticsHandler"
93114
import type { ModeConfig } from "@roo-code/types"
94115

95116
vi.mock("../../../utils/fs")
@@ -739,3 +760,57 @@ describe("webviewMessageHandler - mcpEnabled", () => {
739760
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
740761
})
741762
})
763+
764+
describe("webviewMessageHandler - downloadErrorDiagnostics", () => {
765+
beforeEach(() => {
766+
vi.clearAllMocks()
767+
768+
// Ensure contextProxy has a globalStorageUri for the handler
769+
;(mockClineProvider as any).contextProxy.globalStorageUri = { fsPath: "/mock/global/storage" }
770+
771+
// Provide a current task with a stable ID
772+
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue({
773+
taskId: "test-task-id",
774+
} as any)
775+
})
776+
777+
it("calls generateErrorDiagnostics with correct parameters", async () => {
778+
await webviewMessageHandler(mockClineProvider, {
779+
type: "downloadErrorDiagnostics",
780+
values: {
781+
timestamp: "2025-01-01T00:00:00.000Z",
782+
version: "1.2.3",
783+
provider: "test-provider",
784+
model: "test-model",
785+
details: "Sample error details",
786+
},
787+
} as any)
788+
789+
// Verify generateErrorDiagnostics was called with the correct parameters
790+
expect(generateErrorDiagnostics).toHaveBeenCalledTimes(1)
791+
expect(generateErrorDiagnostics).toHaveBeenCalledWith({
792+
taskId: "test-task-id",
793+
globalStoragePath: "/mock/global/storage",
794+
values: {
795+
timestamp: "2025-01-01T00:00:00.000Z",
796+
version: "1.2.3",
797+
provider: "test-provider",
798+
model: "test-model",
799+
details: "Sample error details",
800+
},
801+
log: expect.any(Function),
802+
})
803+
})
804+
805+
it("shows error when no active task", async () => {
806+
vi.mocked(mockClineProvider.getCurrentTask).mockReturnValue(null as any)
807+
808+
await webviewMessageHandler(mockClineProvider, {
809+
type: "downloadErrorDiagnostics",
810+
values: {},
811+
} as any)
812+
813+
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No active task to generate diagnostics for")
814+
expect(generateErrorDiagnostics).not.toHaveBeenCalled()
815+
})
816+
})

0 commit comments

Comments
 (0)