Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export interface WebviewMessage {
| "getListApiConfiguration"
| "customInstructions"
| "webviewDidLaunch"
| "webviewDidFocus"
| "newTask"
| "askResponse"
| "terminalOperation"
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/api-subtask.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ vi.mock("@roo-code/ipc", () => ({
vi.mock("../services/command/commands", () => ({ getCommands: vi.fn().mockResolvedValue([]) }))

import { API } from "../extension/api"
import { makeClineProviderFactory } from "../test-utils/provider"

function makeProviderMock() {
const emitter = new EventEmitter()
Expand Down Expand Up @@ -63,7 +64,7 @@ describe("API.clearCurrentTask()", () => {
beforeEach(() => {
vi.clearAllMocks()
provider = makeProviderMock()
api = new API({} as any, provider as any)
api = new API({} as any, provider as any, makeClineProviderFactory())
})

it("calls evictCurrentTask then postStateToWebview on sidebarProvider", async () => {
Expand All @@ -84,7 +85,7 @@ describe("API.abandonSubtask()", () => {
beforeEach(() => {
vi.clearAllMocks()
provider = makeProviderMock()
api = new API({} as any, provider as any)
api = new API({} as any, provider as any, makeClineProviderFactory())
})

it("delegates to sidebarProvider.abandonSubtask and returns its result", async () => {
Expand Down
103 changes: 103 additions & 0 deletions src/__tests__/extension.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// npx vitest run __tests__/extension.spec.ts

import type * as vscode from "vscode"
import type { WebviewMessage } from "@roo-code/types"

import { makeCompositeDisposable, makeEventEmitter, makeExtensionContext } from "../test-utils/vscode"

vi.mock("vscode", () => ({
Disposable: { from: (...subscriptions: vscode.Disposable[]) => makeCompositeDisposable(...subscriptions) },
window: {
createOutputChannel: vi.fn().mockReturnValue({
appendLine: vi.fn(),
Expand Down Expand Up @@ -166,6 +170,8 @@ vi.mock("../extension/api", () => ({
}),
}))

vi.mock("../activate/registerCommands", () => ({ openClineInNewTab: vi.fn() }))

vi.mock("../activate", () => ({
handleUri: vi.fn(),
registerCommands: vi.fn(),
Expand Down Expand Up @@ -239,6 +245,103 @@ describe("extension.ts", () => {
settingsUpdatedHandler = undefined
})

test("shares the extension-owned focus tracker with the sidebar and code actions and disposes its listeners", async () => {
vi.resetModules()
const { WebviewFocusTracker } = await import("../core/webview/WebviewFocusTracker")
const { ClineProvider } = await import("../core/webview/ClineProvider")
const { ContextProxy } = await import("../core/config/ContextProxy")
const { registerCodeActions } = await import("../activate")
const vscode = await import("vscode")
const { activate } = await import("../extension")

await activate(mockContext)

const trackers = mockContext.subscriptions.filter((entry) => entry instanceof WebviewFocusTracker)
expect(trackers).toHaveLength(1)
const tracker = trackers[0]
const outputChannel = vi.mocked(vscode.window.createOutputChannel).mock.results[0].value
expect(ClineProvider).toHaveBeenCalledExactlyOnceWith(
mockContext,
outputChannel,
"sidebar",
await ContextProxy.getInstance(mockContext),
tracker,
null,
)
expect(registerCodeActions).toHaveBeenCalledExactlyOnceWith(mockContext, tracker)

const messages = makeEventEmitter<WebviewMessage>()
const disposed = makeEventEmitter<void>()
const provider = ClineProvider.getVisibleInstance()!
tracker.init(provider, { webview: { onDidReceiveMessage: messages.event }, onDidDispose: disposed.event })
messages.fire({ type: "webviewDidFocus" })
expect(tracker.getLastActiveProvider()).toBe(provider)

// Dispose the exact instance owned by the extension, rather than a separate test tracker.
tracker.dispose()
messages.fire({ type: "webviewDidFocus" })
expect(tracker.getLastActiveProvider()).toBeUndefined()
})

test("keeps focus trackers independent across extension activations", async () => {
vi.resetModules()
const { WebviewFocusTracker } = await import("../core/webview/WebviewFocusTracker")
const { registerCodeActions } = await import("../activate")
const { activate } = await import("../extension")
const secretChanges = makeEventEmitter<vscode.SecretStorageChangeEvent>()
const secondContext = makeExtensionContext({
secrets: {
get: vi.fn().mockResolvedValue(undefined),
store: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
onDidChange: secretChanges.event,
},
})

await activate(mockContext)
await activate(secondContext)

const firstTracker = mockContext.subscriptions.find((entry) => entry instanceof WebviewFocusTracker)
const secondTracker = secondContext.subscriptions.find((entry) => entry instanceof WebviewFocusTracker)
expect(firstTracker).toBeInstanceOf(WebviewFocusTracker)
expect(secondTracker).toBeInstanceOf(WebviewFocusTracker)
expect(secondTracker).not.toBe(firstTracker)
expect(registerCodeActions).toHaveBeenNthCalledWith(1, mockContext, firstTracker)
expect(registerCodeActions).toHaveBeenNthCalledWith(2, secondContext, secondTracker)
firstTracker?.dispose()
secondTracker?.dispose()
secretChanges.dispose()
})

test("injects a factory instance with this activation's dependencies without reading the sidebar tracker", async () => {
vi.resetModules()
const { API } = await import("../extension/api")
const { openClineInNewTab } = await import("../activate/registerCommands")
const { ClineProviderFactory } = await import("../core/webview/ClineProviderFactory")
const { WebviewFocusTracker } = await import("../core/webview/WebviewFocusTracker")
const { ClineProvider } = await import("../core/webview/ClineProvider")
const { activate } = await import("../extension")
const vscode = await import("vscode")
await activate(mockContext)

const tracker = mockContext.subscriptions.find((entry) => entry instanceof WebviewFocusTracker)
const outputChannel = vi.mocked(vscode.window.createOutputChannel).mock.results[0].value
const sidebar = vi.mocked(ClineProvider).mock.results[0].value
const providerFactory = vi.mocked(API).mock.calls[0][2]
expect(providerFactory).toBeInstanceOf(ClineProviderFactory)
expect(sidebar.webviewFocusTracker).toBeUndefined()
expect(openClineInNewTab).not.toHaveBeenCalled()
vi.mocked(openClineInNewTab).mockResolvedValueOnce(sidebar)

await expect(providerFactory.createInNewTab()).resolves.toBe(sidebar)
expect(openClineInNewTab).toHaveBeenCalledExactlyOnceWith({
context: mockContext,
outputChannel,
webviewFocusTracker: tracker,
})
tracker?.dispose()
})

test("initializes the code index scope and registers it for extension cleanup", async () => {
vi.resetModules()
const { CodeIndexScope } = await import("../services/code-index/code-index-scope")
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type Task } from "../core/task/Task"
import { API } from "../extension/api"
import * as ProfileValidatorMod from "../shared/ProfileValidator"
import { providerIdentifiers } from "@roo-code/types/provider-identifiers"
import { makeClineProviderFactory } from "../test-utils/provider"

type PrivateClineProviderMethods = {
createTask: (
Expand Down Expand Up @@ -406,7 +407,7 @@ describe("Single-open-task invariant", () => {
} as unknown as ClineProvider

const output = { appendLine: vi.fn() } as unknown as OutputChannel
const api = new API(output, provider, undefined, false)
const api = new API(output, provider, makeClineProviderFactory(), undefined, false)

const taskId = await api.startNewTask({
configuration: {},
Expand Down
120 changes: 120 additions & 0 deletions src/activate/__tests__/handleTask.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as vscode from "vscode"

import { handleNewTask } from "../handleTask"
import { ClineProvider } from "../../core/webview/ClineProvider"
import { WebviewFocusTracker } from "../../core/webview/WebviewFocusTracker"
import { Package } from "../../shared/package"

vi.mock("vscode", () => ({
window: { showInputBox: vi.fn() },
commands: { executeCommand: vi.fn().mockResolvedValue(undefined) },
}))
vi.mock("../../core/webview/ClineProvider", () => ({
ClineProvider: class {
static getInstance = vi.fn()
get isViewVisible() {
return true
}
},
}))
vi.mock("../../i18n", () => ({ t: (key: string) => key }))

describe("handleNewTask", () => {
let provider: ClineProvider
let tracker: WebviewFocusTracker

beforeEach(() => {
vi.clearAllMocks()
tracker = new WebviewFocusTracker()
provider = Object.create(ClineProvider.prototype) as ClineProvider
provider.handleCodeAction = vi.fn().mockResolvedValue(undefined)
vi.mocked(ClineProvider.getInstance).mockResolvedValue(provider)
vi.mocked(vscode.window.showInputBox).mockResolvedValue(undefined)
})

it("executes the supplied prompt on the resolved provider", async () => {
await handleNewTask({ prompt: "new task" }, tracker)
expect(ClineProvider.getInstance).toHaveBeenCalledOnce()
expect(vscode.window.showInputBox).not.toHaveBeenCalled()
expect(provider.handleCodeAction).toHaveBeenCalledWith("newTask", "NEW_TASK", { userInput: "new task" })
})

it("executes the prompt entered in the input dialog", async () => {
vi.mocked(vscode.window.showInputBox).mockResolvedValue("entered task")
await handleNewTask(undefined, tracker)
expect(provider.handleCodeAction).toHaveBeenCalledWith("newTask", "NEW_TASK", { userInput: "entered task" })
})

it("focuses the sidebar without executing an action when input is cancelled", async () => {
const getLastActive = vi.spyOn(tracker, "getLastActiveProvider")
await handleNewTask(undefined, tracker)
expect(vscode.commands.executeCommand).toHaveBeenCalledWith(`${Package.name}.SidebarProvider.focus`)
expect(ClineProvider.getInstance).not.toHaveBeenCalled()
expect(getLastActive).not.toHaveBeenCalled()
expect(provider.handleCodeAction).not.toHaveBeenCalled()
})

it("does nothing when no provider can be resolved", async () => {
vi.mocked(ClineProvider.getInstance).mockResolvedValue(undefined)
await handleNewTask({ prompt: "new task" }, tracker)
expect(provider.handleCodeAction).not.toHaveBeenCalled()
})

it("awaits the provider action and propagates its error", async () => {
const error = new Error("Task creation failed")
vi.mocked(provider.handleCodeAction).mockRejectedValue(error)
await expect(handleNewTask({ prompt: "new task" }, tracker)).rejects.toBe(error)
})

it.each(["supplied", "dialog"])(
"uses the last active visible chat for a %s prompt without resolving another provider",
async (source) => {
vi.spyOn(tracker, "getLastActiveProvider").mockReturnValue(provider)
const fallback = Object.create(ClineProvider.prototype) as ClineProvider
fallback.handleCodeAction = vi.fn().mockResolvedValue(undefined)
vi.mocked(ClineProvider.getInstance).mockResolvedValue(fallback)
vi.mocked(vscode.window.showInputBox).mockResolvedValue("new task")
await handleNewTask(source === "supplied" ? { prompt: "new task" } : undefined, tracker)
expect(provider.handleCodeAction).toHaveBeenCalledWith("newTask", "NEW_TASK", { userInput: "new task" })
expect(ClineProvider.getInstance).not.toHaveBeenCalled()
expect(fallback.handleCodeAction).not.toHaveBeenCalled()
},
)

it.each(["supplied", "dialog"])(
"uses the fallback for a %s prompt when the last active chat is hidden",
async (source) => {
vi.spyOn(provider, "isViewVisible", "get").mockReturnValue(false)
vi.spyOn(tracker, "getLastActiveProvider").mockReturnValue(provider)
const fallback = Object.create(ClineProvider.prototype) as ClineProvider
fallback.handleCodeAction = vi.fn().mockResolvedValue(undefined)
vi.mocked(ClineProvider.getInstance).mockResolvedValue(fallback)
vi.mocked(vscode.window.showInputBox).mockResolvedValue("new task")

await handleNewTask(source === "supplied" ? { prompt: "new task" } : undefined, tracker)

expect(ClineProvider.getInstance).toHaveBeenCalledOnce()
expect(provider.handleCodeAction).not.toHaveBeenCalled()
expect(fallback.handleCodeAction).toHaveBeenCalledExactlyOnceWith("newTask", "NEW_TASK", {
userInput: "new task",
})
},
)

it.each(["supplied", "dialog"])(
"does not execute a %s prompt on a hidden chat when fallback finds no provider",
async (source) => {
vi.spyOn(provider, "isViewVisible", "get").mockReturnValue(false)
vi.spyOn(tracker, "getLastActiveProvider").mockReturnValue(provider)
vi.mocked(ClineProvider.getInstance).mockResolvedValue(undefined)
vi.mocked(vscode.window.showInputBox).mockResolvedValue("new task")

await expect(
handleNewTask(source === "supplied" ? { prompt: "new task" } : undefined, tracker),
).resolves.toBeUndefined()

expect(ClineProvider.getInstance).toHaveBeenCalledOnce()
expect(provider.handleCodeAction).not.toHaveBeenCalled()
},
)
})
Loading
Loading