Skip to content

Commit 2a01eaa

Browse files
easonliang28easonLiangWorldedtech
authored andcommitted
test(api): cover task controls and view-local values
1 parent 8a174db commit 2a01eaa

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,44 @@ describe("ClineProvider - Parallel Mode Support", () => {
10601060
await provider.dispose()
10611061
})
10621062

1063+
it("should merge getValues from ContextProxy with view-local values taking precedence", async () => {
1064+
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
1065+
const providerAccess = provider as unknown as {
1066+
saveViewState: (key: keyof ExtensionState, value: unknown) => Promise<void>
1067+
}
1068+
const contextProxyAccess = provider.contextProxy as unknown as {
1069+
setValues: (values: Partial<ExtensionState>) => Promise<void>
1070+
}
1071+
await contextProxyAccess.setValues({
1072+
mode: "debugger",
1073+
currentApiConfigName: "shared-profile",
1074+
apiConfiguration: {
1075+
apiProvider: "anthropic",
1076+
apiKey: "shared-key",
1077+
},
1078+
customModePrompts: { code: { roleDefinition: "shared" } },
1079+
})
1080+
1081+
await providerAccess.saveViewState("mode", "architect")
1082+
await providerAccess.saveViewState("currentApiConfigName", "view-profile")
1083+
await providerAccess.saveViewState("apiConfiguration", {
1084+
apiProvider: "openrouter",
1085+
openRouterApiKey: "view-key",
1086+
})
1087+
1088+
const values = provider.getValues()
1089+
1090+
expect(values.mode).toBe("architect")
1091+
expect(values.currentApiConfigName).toBe("view-profile")
1092+
expect(values.apiConfiguration).toEqual({
1093+
apiProvider: "openrouter",
1094+
openRouterApiKey: "view-key",
1095+
})
1096+
expect(values.customModePrompts).toEqual({ code: { roleDefinition: "shared" } })
1097+
1098+
await provider.dispose()
1099+
})
1100+
10631101
it("should update viewLocalState apiConfiguration when setValues receives flat provider settings", async () => {
10641102
const provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext))
10651103

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import { EventEmitter } from "events"
2+
3+
import { describe, expect, it, vi, beforeEach, type Mock } from "vitest"
4+
import * as vscode from "vscode"
5+
6+
import { RooCodeEventName, type ModeConfig, type RooCodeSettings } from "@roo-code/types"
7+
8+
import { API } from "../api"
9+
import { ClineProvider } from "../../core/webview/ClineProvider"
10+
11+
const { openClineInNewTabMock } = vi.hoisted(() => ({
12+
openClineInNewTabMock: vi.fn(),
13+
}))
14+
15+
vi.mock("vscode", () => ({
16+
commands: {
17+
executeCommand: vi.fn().mockResolvedValue(undefined),
18+
},
19+
}))
20+
21+
vi.mock("@roo-code/ipc", () => ({
22+
IpcServer: class {},
23+
}))
24+
25+
vi.mock("../../activate/registerCommands", () => ({
26+
openClineInNewTab: openClineInNewTabMock,
27+
}))
28+
29+
vi.mock("../../integrations/terminal/Terminal", () => ({
30+
Terminal: {
31+
getTerminalProfile: vi.fn(),
32+
setTerminalProfile: vi.fn(),
33+
},
34+
}))
35+
36+
vi.mock("../../integrations/terminal/TerminalRegistry", () => ({
37+
TerminalRegistry: {
38+
closeIdleTerminals: vi.fn(),
39+
},
40+
}))
41+
42+
type CreatedTask = {
43+
taskId: string
44+
}
45+
46+
type ProviderDouble = EventEmitter & {
47+
context: vscode.ExtensionContext
48+
evictCurrentTask: Mock<() => Promise<void>>
49+
postStateToWebview: Mock<() => Promise<void>>
50+
postMessageToWebview: Mock<(message: unknown) => Promise<void>>
51+
createTask: Mock<(...args: unknown[]) => Promise<CreatedTask>>
52+
getCurrentTaskStack: Mock<() => string[]>
53+
getCurrentTask: Mock<() => undefined>
54+
getState: Mock<() => Promise<{ customModes?: ModeConfig[] }>>
55+
handleModeSwitch: Mock<(mode: string) => Promise<void>>
56+
viewLaunched: boolean
57+
}
58+
59+
type TaskDouble = EventEmitter & {
60+
taskId: string
61+
parentTaskId?: string
62+
approveAsk: Mock<() => void>
63+
handleWebviewAskResponse: Mock<(response: "messageResponse", text?: string, images?: string[]) => void>
64+
}
65+
66+
const configuration: RooCodeSettings = {}
67+
68+
function asClineProvider(provider: ProviderDouble): ClineProvider {
69+
// ClineProvider has private members, so a structural test double requires an unknown bridge.
70+
return provider as unknown as ClineProvider
71+
}
72+
73+
function createProvider(taskId = "task-1"): ProviderDouble {
74+
const provider = new EventEmitter() as ProviderDouble
75+
provider.context = {} as vscode.ExtensionContext
76+
provider.evictCurrentTask = vi.fn().mockResolvedValue(undefined)
77+
provider.postStateToWebview = vi.fn().mockResolvedValue(undefined)
78+
provider.postMessageToWebview = vi.fn().mockResolvedValue(undefined)
79+
provider.createTask = vi.fn().mockResolvedValue({ taskId })
80+
provider.getCurrentTaskStack = vi.fn().mockReturnValue([])
81+
provider.getCurrentTask = vi.fn().mockReturnValue(undefined)
82+
provider.getState = vi.fn().mockResolvedValue({ customModes: [] })
83+
provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined)
84+
provider.viewLaunched = true
85+
return provider
86+
}
87+
88+
function createTask(taskId: string): TaskDouble {
89+
const task = new EventEmitter() as TaskDouble
90+
task.taskId = taskId
91+
task.approveAsk = vi.fn()
92+
task.handleWebviewAskResponse = vi.fn()
93+
return task
94+
}
95+
96+
describe("API task controls", () => {
97+
let outputChannel: vscode.OutputChannel
98+
let sidebarProvider: ProviderDouble
99+
let api: API
100+
101+
beforeEach(() => {
102+
vi.clearAllMocks()
103+
outputChannel = { appendLine: vi.fn() } as unknown as vscode.OutputChannel
104+
sidebarProvider = createProvider("sidebar-task")
105+
api = new API(outputChannel, asClineProvider(sidebarProvider))
106+
})
107+
108+
describe("startNewTask", () => {
109+
it("reverts and closes existing editors before opening a new tab unless preserveOpenTabs is true", async () => {
110+
const newTabProvider = createProvider("new-tab-task")
111+
openClineInNewTabMock.mockResolvedValue(newTabProvider)
112+
113+
const taskId = await api.startNewTask({ configuration, text: "new task", newTab: true })
114+
115+
expect(taskId).toBe("new-tab-task")
116+
expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(1, "workbench.action.files.revert")
117+
expect(vscode.commands.executeCommand).toHaveBeenNthCalledWith(2, "workbench.action.closeAllEditors")
118+
expect(openClineInNewTabMock).toHaveBeenCalledWith({
119+
context: sidebarProvider.context,
120+
outputChannel,
121+
})
122+
expect(newTabProvider.evictCurrentTask).toHaveBeenCalledOnce()
123+
expect(newTabProvider.createTask).toHaveBeenCalledWith(
124+
"new task",
125+
undefined,
126+
undefined,
127+
{ consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER },
128+
configuration,
129+
)
130+
})
131+
132+
it("opens a new tab without revert or close commands when preserveOpenTabs is true", async () => {
133+
const newTabProvider = createProvider("preserved-tab-task")
134+
openClineInNewTabMock.mockResolvedValue(newTabProvider)
135+
136+
const taskId = await api.startNewTask({
137+
configuration,
138+
text: "keep editors",
139+
newTab: true,
140+
preserveOpenTabs: true,
141+
})
142+
143+
expect(taskId).toBe("preserved-tab-task")
144+
expect(vscode.commands.executeCommand).not.toHaveBeenCalled()
145+
expect(openClineInNewTabMock).toHaveBeenCalledWith({
146+
context: sidebarProvider.context,
147+
outputChannel,
148+
})
149+
expect(newTabProvider.createTask).toHaveBeenCalledWith(
150+
"keep editors",
151+
undefined,
152+
undefined,
153+
{ consecutiveMistakeLimit: Number.MAX_SAFE_INTEGER },
154+
configuration,
155+
)
156+
})
157+
})
158+
159+
describe("task ask registry", () => {
160+
it("returns false when approving an unknown task", async () => {
161+
await expect(api.approveTaskAsk("missing-task")).resolves.toBe(false)
162+
})
163+
164+
it("registers tasks on TaskCreated and approves a task by id", async () => {
165+
const task = createTask("task-to-approve")
166+
167+
sidebarProvider.emit(RooCodeEventName.TaskCreated, task)
168+
169+
await expect(api.approveTaskAsk(task.taskId)).resolves.toBe(true)
170+
expect(task.approveAsk).toHaveBeenCalledOnce()
171+
})
172+
173+
it("removes completed and aborted tasks from the registry", async () => {
174+
const completedTask = createTask("completed-task")
175+
sidebarProvider.emit(RooCodeEventName.TaskCreated, completedTask)
176+
completedTask.emit(RooCodeEventName.TaskCompleted, completedTask.taskId, {}, {})
177+
178+
await expect(api.approveTaskAsk(completedTask.taskId)).resolves.toBe(false)
179+
180+
const abortedTask = createTask("aborted-task")
181+
sidebarProvider.emit(RooCodeEventName.TaskCreated, abortedTask)
182+
abortedTask.emit(RooCodeEventName.TaskAborted)
183+
184+
await expect(api.approveTaskAsk(abortedTask.taskId)).resolves.toBe(false)
185+
})
186+
})
187+
188+
describe("selectTaskFollowupSuggestion", () => {
189+
it("returns false when the task is unknown", async () => {
190+
await expect(
191+
api.selectTaskFollowupSuggestion({ taskId: "missing-task", answer: "Use this" }),
192+
).resolves.toBe(false)
193+
})
194+
195+
it("responds to the task without switching modes when no mode is provided", async () => {
196+
const task = createTask("task-without-mode")
197+
sidebarProvider.emit(RooCodeEventName.TaskCreated, task)
198+
199+
await expect(api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Continue" })).resolves.toBe(
200+
true,
201+
)
202+
203+
expect(sidebarProvider.getState).not.toHaveBeenCalled()
204+
expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled()
205+
expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Continue")
206+
})
207+
208+
it("switches to a valid built-in mode before responding", async () => {
209+
const task = createTask("task-built-in-mode")
210+
sidebarProvider.emit(RooCodeEventName.TaskCreated, task)
211+
212+
await expect(
213+
api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Use architect", mode: "architect" }),
214+
).resolves.toBe(true)
215+
216+
expect(sidebarProvider.getState).toHaveBeenCalledOnce()
217+
expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith("architect")
218+
expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use architect")
219+
})
220+
221+
it("responds without switching modes when the requested mode is invalid", async () => {
222+
const task = createTask("task-invalid-mode")
223+
sidebarProvider.emit(RooCodeEventName.TaskCreated, task)
224+
225+
await expect(
226+
api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Use invalid", mode: "not-a-mode" }),
227+
).resolves.toBe(true)
228+
229+
expect(sidebarProvider.getState).toHaveBeenCalledOnce()
230+
expect(sidebarProvider.handleModeSwitch).not.toHaveBeenCalled()
231+
expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Use invalid")
232+
})
233+
234+
it("treats custom modes from the task provider state as valid", async () => {
235+
const task = createTask("task-custom-mode")
236+
const customMode: ModeConfig = {
237+
slug: "custom-review",
238+
name: "Custom Review",
239+
roleDefinition: "Review the implementation",
240+
groups: ["read"],
241+
}
242+
sidebarProvider.getState.mockResolvedValue({ customModes: [customMode] })
243+
sidebarProvider.emit(RooCodeEventName.TaskCreated, task)
244+
245+
await expect(
246+
api.selectTaskFollowupSuggestion({ taskId: task.taskId, answer: "Review it", mode: customMode.slug }),
247+
).resolves.toBe(true)
248+
249+
expect(sidebarProvider.handleModeSwitch).toHaveBeenCalledWith(customMode.slug)
250+
expect(task.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Review it")
251+
})
252+
})
253+
})

0 commit comments

Comments
 (0)