mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
feat: add 'Add and Run' button for allowed commands (#5290)
- Added new 'Add and Run' button in ChatView that appears when hovering over code blocks containing allowed commands - Button extracts commands from code blocks and adds them to allowed commands list - Automatically switches to Settings view after adding commands - Added comprehensive test coverage for the new functionality - Updated all locale files with new translation keys - Added utility function to extract command patterns from code blocks This implementation allows users to quickly add commands from AI responses to their allowed commands list with a single click.
This commit is contained in:
parent
7645aad435
commit
af8eae5ea1
30 changed files with 1768 additions and 357 deletions
|
|
@ -274,6 +274,41 @@ export async function presentAssistantMessage(cline: Task) {
|
|||
isProtected || false,
|
||||
)
|
||||
|
||||
// Handle "Add & Run" button for command approval
|
||||
if (response === "addAndRunButtonClicked" && type === "command") {
|
||||
// The text field contains the extracted command pattern when "Add & Run" is clicked
|
||||
if (text) {
|
||||
// Add the command pattern to allowed commands
|
||||
const provider = cline.providerRef.deref()
|
||||
if (provider) {
|
||||
// Get current allowed commands
|
||||
const currentState = await provider.getState()
|
||||
const currentAllowedCommands = currentState.allowedCommands || []
|
||||
|
||||
// Add the new pattern if it's not already in the list
|
||||
if (!currentAllowedCommands.includes(text)) {
|
||||
const updatedCommands = [...currentAllowedCommands, text]
|
||||
|
||||
// Update global state using contextProxy
|
||||
await provider.contextProxy.setValue("allowedCommands", updatedCommands)
|
||||
|
||||
// Also update workspace settings
|
||||
const vscode = await import("vscode")
|
||||
const { Package } = await import("../../shared/package")
|
||||
await vscode.workspace
|
||||
.getConfiguration(Package.name)
|
||||
.update("allowedCommands", updatedCommands, vscode.ConfigurationTarget.Global)
|
||||
|
||||
// Post state update to webview
|
||||
await provider.postStateToWebview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return true to indicate approval and continue with command execution
|
||||
return true
|
||||
}
|
||||
|
||||
if (response !== "yesButtonClicked") {
|
||||
// Handle both messageResponse and noButtonClicked with text.
|
||||
if (text) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
import type { Mock } from "vitest"
|
||||
|
||||
// Mock dependencies - must come before imports
|
||||
vi.mock("../../../api/providers/fetchers/modelCache")
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import type { ClineProvider } from "../ClineProvider"
|
||||
import { getModels } from "../../../api/providers/fetchers/modelCache"
|
||||
import type { ModelRecord } from "../../../shared/api"
|
||||
import { ClineProvider } from "../ClineProvider"
|
||||
import * as vscode from "vscode"
|
||||
import { Package } from "../../../shared/package"
|
||||
|
||||
const mockGetModels = getModels as Mock<typeof getModels>
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
workspace: {
|
||||
getConfiguration: vi.fn().mockReturnValue({
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
ConfigurationTarget: {
|
||||
Global: 1,
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock Package
|
||||
vi.mock("../../../shared/package", () => ({
|
||||
Package: {
|
||||
name: "roo-cline",
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ClineProvider
|
||||
const mockClineProvider = {
|
||||
|
|
@ -35,16 +48,6 @@ const mockClineProvider = {
|
|||
|
||||
import { t } from "../../../i18n"
|
||||
|
||||
vi.mock("vscode", () => ({
|
||||
window: {
|
||||
showInformationMessage: vi.fn(),
|
||||
showErrorMessage: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../../../i18n", () => ({
|
||||
t: vi.fn((key: string, args?: Record<string, any>) => {
|
||||
// For the delete confirmation with rules, we need to return the interpolated string
|
||||
|
|
@ -77,7 +80,6 @@ vi.mock("fs/promises", () => {
|
|||
}
|
||||
})
|
||||
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
|
@ -90,282 +92,94 @@ vi.mock("../../../utils/fs")
|
|||
vi.mock("../../../utils/path")
|
||||
vi.mock("../../../utils/globalContext")
|
||||
|
||||
describe("webviewMessageHandler - requestRouterModels", () => {
|
||||
describe("webviewMessageHandler", () => {
|
||||
let mockProvider: any
|
||||
let mockContextProxy: any
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
mockClineProvider.getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
openRouterApiKey: "openrouter-key",
|
||||
requestyApiKey: "requesty-key",
|
||||
glamaApiKey: "glama-key",
|
||||
unboundApiKey: "unbound-key",
|
||||
litellmApiKey: "litellm-key",
|
||||
litellmBaseUrl: "http://localhost:4000",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("successfully fetches models from all providers", async () => {
|
||||
const mockModels: ModelRecord = {
|
||||
"model-1": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
description: "Test model 1",
|
||||
},
|
||||
"model-2": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 16384,
|
||||
supportsPromptCache: false,
|
||||
description: "Test model 2",
|
||||
},
|
||||
// Create mock context proxy
|
||||
mockContextProxy = {
|
||||
getValue: vi.fn(),
|
||||
setValue: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
|
||||
mockGetModels.mockResolvedValue(mockModels)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
})
|
||||
|
||||
// Verify getModels was called for each provider
|
||||
expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" })
|
||||
expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
|
||||
expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
|
||||
expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
|
||||
expect(mockGetModels).toHaveBeenCalledWith({
|
||||
provider: "litellm",
|
||||
apiKey: "litellm-key",
|
||||
baseUrl: "http://localhost:4000",
|
||||
})
|
||||
|
||||
// Verify response was sent
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "routerModels",
|
||||
routerModels: {
|
||||
openrouter: mockModels,
|
||||
requesty: mockModels,
|
||||
glama: mockModels,
|
||||
unbound: mockModels,
|
||||
litellm: mockModels,
|
||||
ollama: {},
|
||||
lmstudio: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("handles LiteLLM models with values from message when config is missing", async () => {
|
||||
mockClineProvider.getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
openRouterApiKey: "openrouter-key",
|
||||
requestyApiKey: "requesty-key",
|
||||
glamaApiKey: "glama-key",
|
||||
unboundApiKey: "unbound-key",
|
||||
// Missing litellm config
|
||||
},
|
||||
})
|
||||
|
||||
const mockModels: ModelRecord = {
|
||||
"model-1": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
description: "Test model 1",
|
||||
},
|
||||
// Create mock provider
|
||||
mockProvider = {
|
||||
contextProxy: mockContextProxy,
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
log: vi.fn(),
|
||||
}
|
||||
|
||||
mockGetModels.mockResolvedValue(mockModels)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
values: {
|
||||
litellmApiKey: "message-litellm-key",
|
||||
litellmBaseUrl: "http://message-url:4000",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify LiteLLM was called with values from message
|
||||
expect(mockGetModels).toHaveBeenCalledWith({
|
||||
provider: "litellm",
|
||||
apiKey: "message-litellm-key",
|
||||
baseUrl: "http://message-url:4000",
|
||||
})
|
||||
})
|
||||
|
||||
it("skips LiteLLM when both config and message values are missing", async () => {
|
||||
mockClineProvider.getState = vi.fn().mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
openRouterApiKey: "openrouter-key",
|
||||
requestyApiKey: "requesty-key",
|
||||
glamaApiKey: "glama-key",
|
||||
unboundApiKey: "unbound-key",
|
||||
// Missing litellm config
|
||||
},
|
||||
describe("allowedCommands", () => {
|
||||
it("should update global state, workspace settings, and call postStateToWebview", async () => {
|
||||
const testCommands = ["npm test", "npm run build", "git status"]
|
||||
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "allowedCommands",
|
||||
commands: testCommands,
|
||||
})
|
||||
|
||||
// Verify global state was updated
|
||||
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", testCommands)
|
||||
|
||||
// Verify workspace settings were updated
|
||||
const mockConfig = vscode.workspace.getConfiguration()
|
||||
expect(vscode.workspace.getConfiguration).toHaveBeenCalledWith(Package.name)
|
||||
expect(mockConfig.update).toHaveBeenCalledWith(
|
||||
"allowedCommands",
|
||||
testCommands,
|
||||
vscode.ConfigurationTarget.Global,
|
||||
)
|
||||
|
||||
// Verify postStateToWebview was called
|
||||
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
const mockModels: ModelRecord = {
|
||||
"model-1": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
description: "Test model 1",
|
||||
},
|
||||
}
|
||||
it("should filter out invalid commands", async () => {
|
||||
const testCommands = ["npm test", "", " ", null, undefined, "git status", 123]
|
||||
|
||||
mockGetModels.mockResolvedValue(mockModels)
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "allowedCommands",
|
||||
commands: testCommands as any,
|
||||
})
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
// No values provided
|
||||
// Should only include valid string commands
|
||||
const expectedCommands = ["npm test", "git status"]
|
||||
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", expectedCommands)
|
||||
})
|
||||
|
||||
// Verify LiteLLM was NOT called
|
||||
expect(mockGetModels).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "litellm",
|
||||
}),
|
||||
)
|
||||
it("should handle empty commands array", async () => {
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "allowedCommands",
|
||||
commands: [],
|
||||
})
|
||||
|
||||
// Verify response includes empty object for LiteLLM
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "routerModels",
|
||||
routerModels: {
|
||||
openrouter: mockModels,
|
||||
requesty: mockModels,
|
||||
glama: mockModels,
|
||||
unbound: mockModels,
|
||||
litellm: {},
|
||||
ollama: {},
|
||||
lmstudio: {},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("handles individual provider failures gracefully", async () => {
|
||||
const mockModels: ModelRecord = {
|
||||
"model-1": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
description: "Test model 1",
|
||||
},
|
||||
}
|
||||
|
||||
// Mock some providers to succeed and others to fail
|
||||
mockGetModels
|
||||
.mockResolvedValueOnce(mockModels) // openrouter
|
||||
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
|
||||
.mockResolvedValueOnce(mockModels) // glama
|
||||
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
|
||||
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
|
||||
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Verify successful providers are included
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "routerModels",
|
||||
routerModels: {
|
||||
openrouter: mockModels,
|
||||
requesty: {},
|
||||
glama: mockModels,
|
||||
unbound: {},
|
||||
litellm: {},
|
||||
ollama: {},
|
||||
lmstudio: {},
|
||||
},
|
||||
it("should handle undefined commands", async () => {
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "allowedCommands",
|
||||
commands: undefined,
|
||||
})
|
||||
|
||||
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
|
||||
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Verify error messages were sent for failed providers
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Requesty API error",
|
||||
values: { provider: "requesty" },
|
||||
})
|
||||
it("should handle non-array commands", async () => {
|
||||
await webviewMessageHandler(mockProvider, {
|
||||
type: "allowedCommands",
|
||||
commands: "not an array" as any,
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Unbound API error",
|
||||
values: { provider: "unbound" },
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "LiteLLM connection failed",
|
||||
values: { provider: "litellm" },
|
||||
})
|
||||
})
|
||||
|
||||
it("handles Error objects and string errors correctly", async () => {
|
||||
// Mock providers to fail with different error types
|
||||
mockGetModels
|
||||
.mockRejectedValueOnce(new Error("Structured error message")) // openrouter
|
||||
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
|
||||
.mockRejectedValueOnce(new Error("Glama API error")) // glama
|
||||
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
|
||||
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
})
|
||||
|
||||
// Verify error handling for different error types
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Structured error message",
|
||||
values: { provider: "openrouter" },
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Requesty API error",
|
||||
values: { provider: "requesty" },
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Glama API error",
|
||||
values: { provider: "glama" },
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "Unbound API error",
|
||||
values: { provider: "unbound" },
|
||||
})
|
||||
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
|
||||
type: "singleRouterModelFetchResponse",
|
||||
success: false,
|
||||
error: "LiteLLM connection failed",
|
||||
values: { provider: "litellm" },
|
||||
})
|
||||
})
|
||||
|
||||
it("prefers config values over message values for LiteLLM", async () => {
|
||||
const mockModels: ModelRecord = {}
|
||||
mockGetModels.mockResolvedValue(mockModels)
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "requestRouterModels",
|
||||
values: {
|
||||
litellmApiKey: "message-key",
|
||||
litellmBaseUrl: "http://message-url",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify config values are used over message values
|
||||
expect(mockGetModels).toHaveBeenCalledWith({
|
||||
provider: "litellm",
|
||||
apiKey: "litellm-key", // From config
|
||||
baseUrl: "http://localhost:4000", // From config
|
||||
expect(mockContextProxy.setValue).toHaveBeenCalledWith("allowedCommands", [])
|
||||
expect(mockProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -582,6 +582,9 @@ export const webviewMessageHandler = async (
|
|||
.getConfiguration(Package.name)
|
||||
.update("allowedCommands", validCommands, vscode.ConfigurationTarget.Global)
|
||||
|
||||
// Post state update to webview to reflect changes in UI
|
||||
await provider.postStateToWebview()
|
||||
|
||||
break
|
||||
}
|
||||
case "openCustomModesSettings": {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,13 @@ export interface ExtensionMessage {
|
|||
| "didBecomeVisible"
|
||||
| "focusInput"
|
||||
| "switchTab"
|
||||
invoke?: "newChat" | "sendMessage" | "primaryButtonClick" | "secondaryButtonClick" | "setChatBoxMessage"
|
||||
invoke?:
|
||||
| "newChat"
|
||||
| "sendMessage"
|
||||
| "primaryButtonClick"
|
||||
| "secondaryButtonClick"
|
||||
| "tertiaryButtonClick"
|
||||
| "setChatBoxMessage"
|
||||
state?: ExtensionState
|
||||
images?: string[]
|
||||
filePaths?: string[]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ import { marketplaceItemSchema } from "@roo-code/types"
|
|||
|
||||
import { Mode } from "./modes"
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" | "objectResponse"
|
||||
export type ClineAskResponse =
|
||||
| "yesButtonClicked"
|
||||
| "noButtonClicked"
|
||||
| "addAndRunButtonClicked"
|
||||
| "messageResponse"
|
||||
| "objectResponse"
|
||||
|
||||
export type PromptMode = Mode | "enhance"
|
||||
|
||||
|
|
@ -232,6 +237,7 @@ export interface WebviewMessage {
|
|||
visibility?: ShareVisibility // For share visibility
|
||||
hasContent?: boolean // For checkRulesDirectoryResult
|
||||
checkOnly?: boolean // For deleteCustomMode check
|
||||
commandPattern?: string // For "Add & Run" button - the extracted command pattern to whitelist
|
||||
codeIndexSettings?: {
|
||||
// Global state settings
|
||||
codebaseIndexEnabled: boolean
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { ProfileValidator } from "@roo/ProfileValidator"
|
|||
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { validateCommand } from "@src/utils/command-validation"
|
||||
import { extractCommandPattern, getPatternDescription } from "@src/utils/extract-command-pattern"
|
||||
import { buildDocLink } from "@src/utils/docLinks"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { useExtensionState } from "@src/context/ExtensionStateContext"
|
||||
|
|
@ -149,6 +150,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const [enableButtons, setEnableButtons] = useState<boolean>(false)
|
||||
const [primaryButtonText, setPrimaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [secondaryButtonText, setSecondaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [tertiaryButtonText, setTertiaryButtonText] = useState<string | undefined>(undefined)
|
||||
const [didClickCancel, setDidClickCancel] = useState(false)
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [expandedRows, setExpandedRows] = useState<Record<number, boolean>>({})
|
||||
|
|
@ -247,6 +249,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:retry.title"))
|
||||
setSecondaryButtonText(t("chat:startNewTask.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "mistake_limit_reached":
|
||||
playSound("progress_loop")
|
||||
|
|
@ -255,6 +258,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:proceedAnyways.title"))
|
||||
setSecondaryButtonText(t("chat:startNewTask.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "followup":
|
||||
if (!isPartial) {
|
||||
|
|
@ -285,15 +289,18 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
case "insertContent":
|
||||
setPrimaryButtonText(t("chat:save.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "finishTask":
|
||||
setPrimaryButtonText(t("chat:completeSubtaskAndReturn"))
|
||||
setSecondaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "readFile":
|
||||
if (tool.batchFiles && Array.isArray(tool.batchFiles)) {
|
||||
setPrimaryButtonText(t("chat:read-batch.approve.title"))
|
||||
setSecondaryButtonText(t("chat:read-batch.deny.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
} else {
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
|
|
@ -323,7 +330,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setClineAsk("command")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText(t("chat:runCommand.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
setSecondaryButtonText(t("chat:addAndRunCommand.title"))
|
||||
setTertiaryButtonText(t("chat:reject.title"))
|
||||
break
|
||||
case "command_output":
|
||||
setSendingDisabled(false)
|
||||
|
|
@ -331,6 +339,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:proceedWhileRunning.title"))
|
||||
setSecondaryButtonText(t("chat:killCommand.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "use_mcp_server":
|
||||
if (!isAutoApproved(lastMessage) && !isPartial) {
|
||||
|
|
@ -341,6 +350,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText(t("chat:approve.title"))
|
||||
setSecondaryButtonText(t("chat:reject.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
break
|
||||
case "completion_result":
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
|
|
@ -359,6 +372,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:resumeTask.title"))
|
||||
setSecondaryButtonText(t("chat:terminate.title"))
|
||||
setTertiaryButtonText(undefined)
|
||||
setDidClickCancel(false) // special case where we reset the cancel button state
|
||||
break
|
||||
case "resume_completed_task":
|
||||
|
|
@ -367,6 +381,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(true)
|
||||
setPrimaryButtonText(t("chat:startNewTask.title"))
|
||||
setSecondaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
}
|
||||
|
|
@ -409,6 +425,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setEnableButtons(false)
|
||||
setPrimaryButtonText(undefined)
|
||||
setSecondaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
setTertiaryButtonText(undefined)
|
||||
}
|
||||
}, [messages.length])
|
||||
|
||||
|
|
@ -597,6 +615,38 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
[clineAsk, startNewTask],
|
||||
)
|
||||
|
||||
const handleTertiaryButtonClick = useCallback(
|
||||
(_text?: string, images?: string[]) => {
|
||||
switch (clineAsk) {
|
||||
case "command":
|
||||
// For the "Add & Run" button on command approval
|
||||
// Extract the command pattern for whitelisting
|
||||
const commandMessage = findLast(
|
||||
messagesRef.current,
|
||||
(msg) => msg.type === "ask" && msg.ask === "command",
|
||||
)
|
||||
const commandText = commandMessage?.text || ""
|
||||
const pattern = extractCommandPattern(commandText)
|
||||
|
||||
// Send the pattern in the text field as expected by the backend
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "addAndRunButtonClicked",
|
||||
text: pattern, // Send pattern in text field
|
||||
images: images || [],
|
||||
})
|
||||
// Clear input state after sending
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
break
|
||||
}
|
||||
setSendingDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
},
|
||||
[clineAsk], // messagesRef is stable
|
||||
)
|
||||
|
||||
const handleSecondaryButtonClick = useCallback(
|
||||
(text?: string, images?: string[]) => {
|
||||
const trimmedInput = text?.trim()
|
||||
|
|
@ -613,7 +663,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
case "resume_task":
|
||||
startNewTask()
|
||||
break
|
||||
case "command":
|
||||
case "tool":
|
||||
case "browser_action_launch":
|
||||
case "use_mcp_server":
|
||||
|
|
@ -633,6 +682,29 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
break
|
||||
case "command":
|
||||
// When there are three buttons, secondary button is "Add & Run"
|
||||
// This case should not happen as we're using tertiary button for reject
|
||||
// But keeping for backward compatibility
|
||||
if (tertiaryButtonText) {
|
||||
// Three button mode - this shouldn't be called
|
||||
console.warn("Secondary button clicked in three-button command mode")
|
||||
} else {
|
||||
// Two button mode - reject
|
||||
if (trimmedInput || (images && images.length > 0)) {
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "noButtonClicked",
|
||||
text: trimmedInput,
|
||||
images: images,
|
||||
})
|
||||
} else {
|
||||
vscode.postMessage({ type: "askResponse", askResponse: "noButtonClicked" })
|
||||
}
|
||||
setInputValue("")
|
||||
setSelectedImages([])
|
||||
}
|
||||
break
|
||||
case "command_output":
|
||||
vscode.postMessage({ type: "terminalOperation", terminalOperation: "abort" })
|
||||
break
|
||||
|
|
@ -641,7 +713,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setClineAsk(undefined)
|
||||
setEnableButtons(false)
|
||||
},
|
||||
[clineAsk, startNewTask, isStreaming],
|
||||
[clineAsk, startNewTask, isStreaming, tertiaryButtonText],
|
||||
)
|
||||
|
||||
const handleTaskCloseButtonClick = useCallback(() => startNewTask(), [startNewTask])
|
||||
|
|
@ -695,6 +767,9 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
case "secondaryButtonClick":
|
||||
handleSecondaryButtonClick(message.text ?? "", message.images ?? [])
|
||||
break
|
||||
case "tertiaryButtonClick":
|
||||
handleTertiaryButtonClick(message.text ?? "", message.images ?? [])
|
||||
break
|
||||
}
|
||||
break
|
||||
case "condenseTaskContextResponse":
|
||||
|
|
@ -721,6 +796,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
handleSetChatBoxMessage,
|
||||
handlePrimaryButtonClick,
|
||||
handleSecondaryButtonClick,
|
||||
handleTertiaryButtonClick,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -1592,12 +1668,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
</div>
|
||||
{areButtonsVisible && (
|
||||
<div
|
||||
className={`flex h-9 items-center mb-1 px-[15px] ${
|
||||
showScrollToBottom
|
||||
? "opacity-100"
|
||||
: enableButtons || (isStreaming && !didClickCancel)
|
||||
className={`${
|
||||
primaryButtonText || secondaryButtonText || tertiaryButtonText || isStreaming
|
||||
? "px-[15px] pt-[10px]"
|
||||
: "p-0"
|
||||
} ${
|
||||
primaryButtonText || secondaryButtonText || tertiaryButtonText || isStreaming
|
||||
? enableButtons || (isStreaming && !didClickCancel)
|
||||
? "opacity-100"
|
||||
: "opacity-50"
|
||||
: ""
|
||||
}`}>
|
||||
{showScrollToBottom ? (
|
||||
<StandardTooltip content={t("chat:scrollToBottom")}>
|
||||
|
|
@ -1613,59 +1693,111 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
</StandardTooltip>
|
||||
) : (
|
||||
<>
|
||||
{primaryButtonText && !isStreaming && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
primaryButtonText === t("chat:retry.title")
|
||||
? t("chat:retry.tooltip")
|
||||
: primaryButtonText === t("chat:save.title")
|
||||
? t("chat:save.tooltip")
|
||||
: primaryButtonText === t("chat:approve.title")
|
||||
? t("chat:approve.tooltip")
|
||||
: primaryButtonText === t("chat:runCommand.title")
|
||||
? t("chat:runCommand.tooltip")
|
||||
: primaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: primaryButtonText === t("chat:resumeTask.title")
|
||||
? t("chat:resumeTask.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedAnyways.title")
|
||||
? t("chat:proceedAnyways.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedWhileRunning.title")
|
||||
? t("chat:proceedWhileRunning.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{(secondaryButtonText || isStreaming) && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
isStreaming
|
||||
? t("chat:cancel.tooltip")
|
||||
: secondaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: secondaryButtonText === t("chat:reject.title")
|
||||
? t("chat:reject.tooltip")
|
||||
: secondaryButtonText === t("chat:terminate.title")
|
||||
? t("chat:terminate.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
|
||||
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
{/* Three button layout for command approval */}
|
||||
{tertiaryButtonText && clineAsk === "command" && !isStreaming ? (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{/* Top row: Run and Add & Run */}
|
||||
<div className="flex gap-[6px]">
|
||||
<StandardTooltip content={t("chat:runCommand.tooltip")}>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
className="flex-1"
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
<StandardTooltip
|
||||
content={(() => {
|
||||
const commandMessage = findLast(
|
||||
messagesRef.current,
|
||||
(msg) => msg.type === "ask" && msg.ask === "command",
|
||||
)
|
||||
const commandText = commandMessage?.text || ""
|
||||
const pattern = extractCommandPattern(commandText)
|
||||
const description = getPatternDescription(pattern)
|
||||
return pattern
|
||||
? `${t("chat:addAndRunCommand.tooltip")} Will whitelist: "${pattern}" (${description})`
|
||||
: t("chat:addAndRunCommand.tooltip")
|
||||
})()}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons}
|
||||
className="flex-1"
|
||||
onClick={() => handleTertiaryButtonClick(inputValue, selectedImages)}>
|
||||
{secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
{/* Bottom row: Reject */}
|
||||
<StandardTooltip content={t("chat:reject.tooltip")}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons}
|
||||
className="w-full"
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{tertiaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
</div>
|
||||
) : (
|
||||
/* Standard two button layout */
|
||||
<div className="flex">
|
||||
{primaryButtonText && !isStreaming && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
primaryButtonText === t("chat:retry.title")
|
||||
? t("chat:retry.tooltip")
|
||||
: primaryButtonText === t("chat:save.title")
|
||||
? t("chat:save.tooltip")
|
||||
: primaryButtonText === t("chat:approve.title")
|
||||
? t("chat:approve.tooltip")
|
||||
: primaryButtonText === t("chat:runCommand.title")
|
||||
? t("chat:runCommand.tooltip")
|
||||
: primaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: primaryButtonText === t("chat:resumeTask.title")
|
||||
? t("chat:resumeTask.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedAnyways.title")
|
||||
? t("chat:proceedAnyways.tooltip")
|
||||
: primaryButtonText ===
|
||||
t("chat:proceedWhileRunning.title")
|
||||
? t("chat:proceedWhileRunning.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<VSCodeButton
|
||||
appearance="primary"
|
||||
disabled={!enableButtons}
|
||||
className={secondaryButtonText ? "flex-1 mr-[6px]" : "flex-[2] mr-0"}
|
||||
onClick={() => handlePrimaryButtonClick(inputValue, selectedImages)}>
|
||||
{primaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
{(secondaryButtonText || isStreaming) && (
|
||||
<StandardTooltip
|
||||
content={
|
||||
isStreaming
|
||||
? t("chat:cancel.tooltip")
|
||||
: secondaryButtonText === t("chat:startNewTask.title")
|
||||
? t("chat:startNewTask.tooltip")
|
||||
: secondaryButtonText === t("chat:reject.title")
|
||||
? t("chat:reject.tooltip")
|
||||
: secondaryButtonText === t("chat:terminate.title")
|
||||
? t("chat:terminate.tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<VSCodeButton
|
||||
appearance="secondary"
|
||||
disabled={!enableButtons && !(isStreaming && !didClickCancel)}
|
||||
className={isStreaming ? "flex-[2] ml-0" : "flex-1 ml-[6px]"}
|
||||
onClick={() => handleSecondaryButtonClick(inputValue, selectedImages)}>
|
||||
{isStreaming ? t("chat:cancel.title") : secondaryButtonText}
|
||||
</VSCodeButton>
|
||||
</StandardTooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,424 @@
|
|||
import React from "react"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { vi } from "vitest"
|
||||
import ChatView from "../ChatView"
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
import { TranslationProvider } from "@src/i18n/TranslationContext"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
// Mock vscode API
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock extract-command-pattern
|
||||
vi.mock("@src/utils/extract-command-pattern", () => ({
|
||||
extractCommandPattern: vi.fn((command: string) => {
|
||||
// Simple mock implementation
|
||||
if (command === "npm test") return "npm test"
|
||||
if (command === "cd /path/to/project && npm run build:prod --verbose") return "cd * && npm run *"
|
||||
return command
|
||||
}),
|
||||
getPatternDescription: vi.fn(() => "matches similar commands"),
|
||||
}))
|
||||
|
||||
// Mock use-sound
|
||||
vi.mock("use-sound", () => ({
|
||||
default: () => [vi.fn()],
|
||||
}))
|
||||
|
||||
// Mock react-use
|
||||
vi.mock("react-use", () => ({
|
||||
useEvent: vi.fn(),
|
||||
useMount: vi.fn(),
|
||||
useDeepCompareEffect: (fn: () => void, deps: any[]) => {
|
||||
// Use regular useEffect for testing
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const React = require("react")
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
React.useEffect(fn, deps)
|
||||
},
|
||||
useWindowSize: () => ({ width: 1024, height: 768 }),
|
||||
}))
|
||||
|
||||
// Mock debounce
|
||||
vi.mock("debounce", () => ({
|
||||
default: (fn: any) => fn,
|
||||
}))
|
||||
|
||||
// Mock react-virtuoso
|
||||
vi.mock("react-virtuoso", () => ({
|
||||
Virtuoso: ({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => <div key={index}>{itemContent(index, item)}</div>)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock all problematic dependencies
|
||||
vi.mock("rehype-highlight", () => ({
|
||||
default: () => () => {},
|
||||
}))
|
||||
|
||||
vi.mock("hast-util-to-text", () => ({
|
||||
default: () => "",
|
||||
}))
|
||||
|
||||
// Mock components that use ESM dependencies
|
||||
vi.mock("../BrowserSessionRow", () => ({
|
||||
default: function MockBrowserSessionRow({ messages }: { messages: any[] }) {
|
||||
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../ChatRow", () => ({
|
||||
default: function MockChatRow({ message }: { message: any }) {
|
||||
// Render the buttons if this is a command ask message
|
||||
if (message.type === "ask" && message.ask === "command") {
|
||||
return (
|
||||
<div data-testid="chat-row">
|
||||
<div>Command: {message.text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../TaskHeader", () => ({
|
||||
default: function MockTaskHeader({ task }: { task: any }) {
|
||||
return <div data-testid="task-header">Task: {task.text}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../AutoApproveMenu", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/common/CodeBlock", () => ({
|
||||
default: () => null,
|
||||
CODE_BLOCK_BG_COLOR: "rgb(30, 30, 30)",
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/common/CodeAccordian", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
vi.mock("@src/components/chat/ContextMenu", () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
// Mock i18n setup
|
||||
vi.mock("@src/i18n/setup", () => {
|
||||
const mockT = (key: string, _options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
"chat:runCommand.title": "Run Command",
|
||||
"chat:addAndRunCommand.title": "Add & Run",
|
||||
"chat:reject.title": "Reject",
|
||||
"chat:typeMessage": "Type a message...",
|
||||
"chat:typeTask": "Type a task...",
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
|
||||
const mockI18n = {
|
||||
language: "en",
|
||||
changeLanguage: vi.fn(),
|
||||
t: mockT,
|
||||
use: vi.fn().mockReturnThis(),
|
||||
init: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
return {
|
||||
default: mockI18n,
|
||||
loadTranslations: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => {
|
||||
const mockT = (key: string, _options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
"chat:runCommand.title": "Run Command",
|
||||
"chat:addAndRunCommand.title": "Add & Run",
|
||||
"chat:reject.title": "Reject",
|
||||
"chat:typeMessage": "Type a message...",
|
||||
"chat:typeTask": "Type a task...",
|
||||
}
|
||||
return translations[key] || key
|
||||
}
|
||||
|
||||
const mockI18n = {
|
||||
language: "en",
|
||||
changeLanguage: vi.fn(),
|
||||
t: mockT,
|
||||
use: vi.fn().mockReturnThis(),
|
||||
init: vi.fn().mockReturnThis(),
|
||||
}
|
||||
|
||||
return {
|
||||
useTranslation: () => ({
|
||||
t: mockT,
|
||||
i18n: mockI18n,
|
||||
}),
|
||||
Trans: ({ i18nKey, children: _children }: any) => <span>{i18nKey}</span>,
|
||||
initReactI18next: {
|
||||
type: "3rdParty",
|
||||
init: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Mock window.postMessage to trigger state hydration
|
||||
const mockPostMessage = (state: any) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "state",
|
||||
state: {
|
||||
version: "1.0.0",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
allowedCommands: [],
|
||||
alwaysAllowExecute: false,
|
||||
autoApprovalEnabled: false,
|
||||
alwaysAllowBrowser: false,
|
||||
alwaysAllowReadOnly: false,
|
||||
alwaysAllowReadOnlyOutsideWorkspace: false,
|
||||
alwaysAllowWrite: false,
|
||||
alwaysAllowWriteOutsideWorkspace: false,
|
||||
alwaysAllowWriteProtected: false,
|
||||
alwaysAllowMcp: false,
|
||||
alwaysAllowModeSwitch: false,
|
||||
alwaysAllowSubtasks: false,
|
||||
writeDelayMs: 0,
|
||||
mode: "code",
|
||||
customModes: [],
|
||||
telemetrySetting: "enabled",
|
||||
hasSystemPromptOverride: false,
|
||||
historyPreviewCollapsed: false,
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
cwd: "/test",
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
currentApiConfigName: "test-config",
|
||||
listApiConfigMeta: [],
|
||||
pinnedApiConfigs: {},
|
||||
customModePrompts: {},
|
||||
codebaseIndexConfig: { codebaseIndexEnabled: false },
|
||||
...state,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}
|
||||
|
||||
describe("ChatView - Add & Run Button", () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const renderChatView = () => {
|
||||
return render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<TranslationProvider>
|
||||
<ChatView isHidden={false} showAnnouncement={false} hideAnnouncement={() => {}} />
|
||||
</TranslationProvider>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Mock window.AUDIO_BASE_URI
|
||||
;(window as any).AUDIO_BASE_URI = ""
|
||||
})
|
||||
|
||||
it("should display three buttons for command approval", async () => {
|
||||
renderChatView()
|
||||
|
||||
// Hydrate state with a task message first, then a command ask
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Initial task",
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: "npm test",
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the buttons to appear
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("Run Command")).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
expect(screen.getByText("Add & Run")).toBeInTheDocument()
|
||||
expect(screen.getByText("Reject")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should send the command pattern when Add & Run button is clicked", async () => {
|
||||
renderChatView()
|
||||
|
||||
// Hydrate state with a task message first, then a command ask
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Initial task",
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: "npm test",
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the buttons to appear
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("Run Command")).toBeInTheDocument()
|
||||
expect(screen.getByText("Add & Run")).toBeInTheDocument()
|
||||
expect(screen.getByText("Reject")).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
// Click the Add & Run button
|
||||
const addAndRunButton = screen.getByText("Add & Run")
|
||||
fireEvent.click(addAndRunButton)
|
||||
|
||||
// Verify the correct message was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "askResponse",
|
||||
askResponse: "addAndRunButtonClicked",
|
||||
text: "npm test", // The extracted pattern
|
||||
images: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should extract and send the correct pattern for complex commands", async () => {
|
||||
const complexCommand = "cd /path/to/project && npm run build:prod --verbose"
|
||||
|
||||
renderChatView()
|
||||
|
||||
// Hydrate state with a task message first, then a command ask
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Build project",
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: complexCommand,
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the buttons to appear
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("Add & Run")).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
// Click the Add & Run button
|
||||
const addAndRunButton = screen.getByText("Add & Run")
|
||||
fireEvent.click(addAndRunButton)
|
||||
|
||||
// Verify the correct pattern was extracted and sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "askResponse",
|
||||
askResponse: "addAndRunButtonClicked",
|
||||
text: "cd * && npm run *", // The extracted pattern
|
||||
images: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle commands with user input", async () => {
|
||||
renderChatView()
|
||||
|
||||
// Hydrate state with a task message first, then a command ask
|
||||
mockPostMessage({
|
||||
clineMessages: [
|
||||
{
|
||||
type: "say",
|
||||
say: "task",
|
||||
ts: Date.now() - 2000,
|
||||
text: "Initial task",
|
||||
partial: false,
|
||||
},
|
||||
{
|
||||
type: "ask",
|
||||
ask: "command",
|
||||
ts: Date.now(),
|
||||
text: "npm test",
|
||||
partial: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Wait for the buttons and input to appear
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("Add & Run")).toBeInTheDocument()
|
||||
},
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
|
||||
// Type some user input
|
||||
const textarea = screen.getByPlaceholderText(/Type a message/i)
|
||||
fireEvent.change(textarea, { target: { value: "additional feedback" } })
|
||||
|
||||
// Click the Add & Run button
|
||||
const addAndRunButton = screen.getByText("Add & Run")
|
||||
fireEvent.click(addAndRunButton)
|
||||
|
||||
// Verify the pattern was sent (not the user input)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "askResponse",
|
||||
askResponse: "addAndRunButtonClicked",
|
||||
text: "npm test", // The extracted pattern, not the user input
|
||||
images: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -200,6 +200,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
}
|
||||
}, [settingsImportedAt, extensionState])
|
||||
|
||||
// Update cached state when allowedCommands changes from external sources (e.g., "Add & Run")
|
||||
useEffect(() => {
|
||||
// Only update if the allowedCommands have actually changed
|
||||
if (JSON.stringify(cachedState.allowedCommands) !== JSON.stringify(extensionState.allowedCommands)) {
|
||||
setCachedState((prevCachedState) => ({
|
||||
...prevCachedState,
|
||||
allowedCommands: extensionState.allowedCommands,
|
||||
}))
|
||||
}
|
||||
}, [extensionState.allowedCommands, cachedState.allowedCommands])
|
||||
|
||||
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
|
||||
setCachedState((prevState) => {
|
||||
if (prevState[field] === value) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,410 @@
|
|||
import React from "react"
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react"
|
||||
import { vi } from "vitest"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
import SettingsView from "../SettingsView"
|
||||
|
||||
// Mock vscode API
|
||||
vi.mock("@src/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock ApiConfigManager
|
||||
vi.mock("../ApiConfigManager", () => ({
|
||||
__esModule: true,
|
||||
default: ({ currentApiConfigName }: any) => (
|
||||
<div data-testid="api-config-management">
|
||||
<span>Current config: {currentApiConfigName}</span>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock VSCode UI toolkit components
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) =>
|
||||
appearance === "icon" ? (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="codicon codicon-close"
|
||||
aria-label="Remove command"
|
||||
data-testid={dataTestId}>
|
||||
<span className="codicon codicon-close" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onClick} data-appearance={appearance} data-testid={dataTestId}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
VSCodeCheckbox: ({ children, onChange, checked, "data-testid": dataTestId }: any) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
|
||||
aria-label={typeof children === "string" ? children : undefined}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
VSCodeTextField: ({ value, onInput, placeholder, "data-testid": dataTestId }: any) => (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onInput({ target: { value: e.target.value } })}
|
||||
placeholder={placeholder}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
),
|
||||
VSCodeLink: ({ children, href }: any) => <a href={href || "#"}>{children}</a>,
|
||||
VSCodeRadio: ({ value, checked, onChange }: any) => (
|
||||
<input type="radio" value={value} checked={checked} onChange={onChange} />
|
||||
),
|
||||
VSCodeRadioGroup: ({ children, onChange }: any) => <div onChange={onChange}>{children}</div>,
|
||||
}))
|
||||
|
||||
// Mock Tab components
|
||||
vi.mock("../../../components/common/Tab", () => ({
|
||||
...vi.importActual("../../../components/common/Tab"),
|
||||
Tab: ({ children }: any) => <div data-testid="tab-container">{children}</div>,
|
||||
TabHeader: ({ children }: any) => <div data-testid="tab-header">{children}</div>,
|
||||
TabContent: ({ children }: any) => <div data-testid="tab-content">{children}</div>,
|
||||
TabList: ({ children, value, onValueChange, "data-testid": dataTestId }: any) => {
|
||||
// Store onValueChange in a global variable so TabTrigger can access it
|
||||
;(window as any).__onValueChange = onValueChange
|
||||
return (
|
||||
<div data-testid={dataTestId} data-value={value}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
TabTrigger: ({ children, value, "data-testid": dataTestId, onClick, isSelected }: any) => {
|
||||
// This function simulates clicking on a tab and making its content visible
|
||||
const handleClick = () => {
|
||||
if (onClick) onClick()
|
||||
// Access onValueChange from the global variable
|
||||
const onValueChange = (window as any).__onValueChange
|
||||
if (onValueChange) onValueChange(value)
|
||||
// Make all tab contents invisible
|
||||
document.querySelectorAll("[data-tab-content]").forEach((el) => {
|
||||
;(el as HTMLElement).style.display = "none"
|
||||
})
|
||||
// Make this tab's content visible
|
||||
const tabContent = document.querySelector(`[data-tab-content="${value}"]`)
|
||||
if (tabContent) {
|
||||
;(tabContent as HTMLElement).style.display = "block"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button data-testid={dataTestId} data-value={value} data-selected={isSelected} onClick={handleClick}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/components/ui", () => ({
|
||||
...vi.importActual("@/components/ui"),
|
||||
Slider: ({ value, onValueChange, "data-testid": dataTestId }: any) => (
|
||||
<input
|
||||
type="range"
|
||||
value={value[0]}
|
||||
onChange={(e) => onValueChange([parseFloat(e.target.value)])}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
),
|
||||
Button: ({ children, onClick, variant, className, "data-testid": dataTestId }: any) => (
|
||||
<button onClick={onClick} data-variant={variant} className={className} data-testid={dataTestId}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
StandardTooltip: ({ children, content }: any) => <div title={content}>{children}</div>,
|
||||
TooltipProvider: ({ children }: any) => <>{children}</>,
|
||||
Input: ({ value, onChange, placeholder, "data-testid": dataTestId }: any) => (
|
||||
<input type="text" value={value} onChange={onChange} placeholder={placeholder} data-testid={dataTestId} />
|
||||
),
|
||||
Select: ({ children, value, onValueChange }: any) => (
|
||||
<div data-testid="select" data-value={value}>
|
||||
<button onClick={() => onValueChange && onValueChange("test-change")}>{value}</button>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
|
||||
SelectGroup: ({ children }: any) => <div data-testid="select-group">{children}</div>,
|
||||
SelectItem: ({ children, value }: any) => (
|
||||
<div data-testid={`select-item-${value}`} data-value={value}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
|
||||
SelectValue: ({ placeholder }: any) => <div data-testid="select-value">{placeholder}</div>,
|
||||
AlertDialog: ({ children, open }: any) => (
|
||||
<div data-testid="alert-dialog" data-open={open}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
AlertDialogContent: ({ children }: any) => <div data-testid="alert-dialog-content">{children}</div>,
|
||||
AlertDialogHeader: ({ children }: any) => <div data-testid="alert-dialog-header">{children}</div>,
|
||||
AlertDialogTitle: ({ children }: any) => <div data-testid="alert-dialog-title">{children}</div>,
|
||||
AlertDialogDescription: ({ children }: any) => <div data-testid="alert-dialog-description">{children}</div>,
|
||||
AlertDialogFooter: ({ children }: any) => <div data-testid="alert-dialog-footer">{children}</div>,
|
||||
AlertDialogAction: ({ children, onClick }: any) => (
|
||||
<button data-testid="alert-dialog-action" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
AlertDialogCancel: ({ children, onClick }: any) => (
|
||||
<button data-testid="alert-dialog-cancel" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock window.postMessage to trigger state hydration
|
||||
const mockPostMessage = (state: any) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "state",
|
||||
state: {
|
||||
version: "1.0.0",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
allowedCommands: [],
|
||||
alwaysAllowExecute: false,
|
||||
ttsEnabled: false,
|
||||
ttsSpeed: 1,
|
||||
soundEnabled: false,
|
||||
soundVolume: 0.5,
|
||||
...state,
|
||||
},
|
||||
},
|
||||
"*",
|
||||
)
|
||||
}
|
||||
|
||||
const renderSettingsView = () => {
|
||||
const onDone = vi.fn()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsView onDone={onDone} />
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
// Hydrate initial state.
|
||||
mockPostMessage({})
|
||||
|
||||
// Helper function to activate a tab by clicking it
|
||||
const activateTab = async (tabId: string) => {
|
||||
const tabButton = screen.getByTestId(`tab-${tabId}`)
|
||||
fireEvent.click(tabButton)
|
||||
// Wait for the tab content to be visible
|
||||
await waitFor(() => {
|
||||
// The tab should be marked as selected
|
||||
expect(tabButton).toHaveAttribute("data-selected", "true")
|
||||
})
|
||||
}
|
||||
|
||||
return { onDone, activateTab }
|
||||
}
|
||||
|
||||
describe("SettingsView - allowedCommands external updates", () => {
|
||||
const mockVscodePostMessage = vi.mocked(vscode.postMessage)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should update cached allowedCommands when extension state changes externally", async () => {
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Wait for allowed commands section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Initially, there should be no allowed commands
|
||||
expect(screen.queryByText("npm test")).not.toBeInTheDocument()
|
||||
|
||||
// Simulate external state update (like from "Add & Run")
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test", "git status"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// Wait for the UI to update
|
||||
await waitFor(() => {
|
||||
// Check that the new commands appear in the UI
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
expect(screen.getByText("git status")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Verify that no save message was sent (since this was an external update)
|
||||
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "allowedCommands",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multiple external updates to allowedCommands", async () => {
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// First update
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Second update (adding more commands)
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test", "npm run build", "echo hello"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
expect(screen.getByText("npm run build")).toBeInTheDocument()
|
||||
expect(screen.getByText("echo hello")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("should not mark settings as changed when allowedCommands update externally", async () => {
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Get initial state of save button
|
||||
const saveButton = screen.getByTestId("save-button")
|
||||
const initialClasses = saveButton.className
|
||||
|
||||
// External update
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Save button should maintain its initial state (not change due to external update)
|
||||
expect(saveButton.className).toBe(initialClasses)
|
||||
|
||||
// Verify that no save message was sent (since this was an external update)
|
||||
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "allowedCommands",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should replace user changes when external updates occur", async () => {
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Wait for allowed commands section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add a command manually
|
||||
const input = screen.getByTestId("command-input")
|
||||
fireEvent.change(input, { target: { value: "npm start" } })
|
||||
const addButton = screen.getByTestId("add-command-button")
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Wait for VSCode message to be sent
|
||||
await waitFor(() => {
|
||||
expect(mockVscodePostMessage).toHaveBeenCalledWith({
|
||||
type: "allowedCommands",
|
||||
commands: ["npm start"],
|
||||
})
|
||||
})
|
||||
|
||||
// Simulate the state update that would come from VSCode after adding the command
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm start"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// The command should appear in the UI
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm start")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Clear the mock to ensure we don't count the manual addition
|
||||
mockVscodePostMessage.mockClear()
|
||||
|
||||
// External update with different commands
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test", "git status"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// Wait for external commands to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
expect(screen.getByText("git status")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The manually added command should be replaced by the external update
|
||||
expect(screen.queryByText("npm start")).not.toBeInTheDocument()
|
||||
|
||||
// Verify that no save message was sent (since this was an external update)
|
||||
expect(mockVscodePostMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "allowedCommands",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { render, screen, fireEvent } from "@/utils/test-utils"
|
||||
import { render, screen, fireEvent, waitFor } from "@/utils/test-utils"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
|
||||
import { vscode } from "@/utils/vscode"
|
||||
|
|
@ -412,17 +412,22 @@ describe("SettingsView - Allowed Commands", () => {
|
|||
expect(screen.getByTestId("command-input")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("adds new command to the list", () => {
|
||||
it("adds new command to the list", async () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
activateTab("autoApprove")
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Wait for allowed commands section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add a new command
|
||||
const input = screen.getByTestId("command-input")
|
||||
fireEvent.change(input, { target: { value: "npm test" } })
|
||||
|
|
@ -430,40 +435,61 @@ describe("SettingsView - Allowed Commands", () => {
|
|||
const addButton = screen.getByTestId("add-command-button")
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Verify command was added
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
|
||||
// Verify VSCode message was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "allowedCommands",
|
||||
commands: ["npm test"],
|
||||
})
|
||||
|
||||
// Simulate the state update that would come from VSCode
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// Wait for command to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it("removes command from the list", () => {
|
||||
it("removes command from the list", async () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
activateTab("autoApprove")
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Wait for allowed commands section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add a command
|
||||
const input = screen.getByTestId("command-input")
|
||||
fireEvent.change(input, { target: { value: "npm test" } })
|
||||
const addButton = screen.getByTestId("add-command-button")
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Simulate the state update after adding
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// Wait for command to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Remove the command
|
||||
const removeButton = screen.getByTestId("remove-command-0")
|
||||
fireEvent.click(removeButton)
|
||||
|
||||
// Verify command was removed
|
||||
expect(screen.queryByText("npm test")).not.toBeInTheDocument()
|
||||
|
||||
// Verify VSCode message was sent
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "allowedCommands",
|
||||
|
|
@ -530,32 +556,65 @@ describe("SettingsView - Duplicate Commands", () => {
|
|||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("prevents duplicate commands", () => {
|
||||
it("prevents duplicate commands", async () => {
|
||||
// Render once and get the activateTab helper
|
||||
const { activateTab } = renderSettingsView()
|
||||
|
||||
// Activate the autoApprove tab
|
||||
activateTab("autoApprove")
|
||||
await activateTab("autoApprove")
|
||||
|
||||
// Enable always allow execute
|
||||
const executeCheckbox = screen.getByTestId("always-allow-execute-toggle")
|
||||
fireEvent.click(executeCheckbox)
|
||||
|
||||
// Add a command twice
|
||||
// Wait for allowed commands section to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("allowed-commands-heading")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Add a command
|
||||
const input = screen.getByTestId("command-input")
|
||||
const addButton = screen.getByTestId("add-command-button")
|
||||
|
||||
// First addition
|
||||
fireEvent.change(input, { target: { value: "npm test" } })
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Second addition attempt
|
||||
// Verify the postMessage was sent
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "allowedCommands",
|
||||
commands: ["npm test"],
|
||||
})
|
||||
|
||||
// Simulate the state update from VSCode
|
||||
mockPostMessage({
|
||||
alwaysAllowExecute: true,
|
||||
allowedCommands: ["npm test"],
|
||||
})
|
||||
|
||||
// Wait for the command to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("npm test")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Try to add the same command again
|
||||
fireEvent.change(input, { target: { value: "npm test" } })
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Verify command appears only once
|
||||
const commands = screen.getAllByText("npm test")
|
||||
expect(commands).toHaveLength(1)
|
||||
// The postMessage should not add a duplicate
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "allowedCommands",
|
||||
commands: ["npm test"],
|
||||
})
|
||||
|
||||
// Add a different command
|
||||
fireEvent.change(input, { target: { value: "npm run build" } })
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Now it should have both commands
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "allowedCommands",
|
||||
commands: ["npm test", "npm run build"],
|
||||
})
|
||||
})
|
||||
|
||||
it("saves allowed commands when clicking Save", () => {
|
||||
|
|
@ -575,6 +634,12 @@ describe("SettingsView - Duplicate Commands", () => {
|
|||
const addButton = screen.getByTestId("add-command-button")
|
||||
fireEvent.click(addButton)
|
||||
|
||||
// Simulate the state update after adding
|
||||
mockPostMessage({
|
||||
allowedCommands: ["npm test"],
|
||||
alwaysAllowExecute: true,
|
||||
})
|
||||
|
||||
// Click Save - use getAllByTestId to handle multiple elements
|
||||
const saveButtons = screen.getAllByTestId("save-button")
|
||||
fireEvent.click(saveButtons[0])
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Executar ordre",
|
||||
"tooltip": "Executa aquesta ordre"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Afegeix i executa",
|
||||
"tooltip": "Afegeix el patró de comanda a la llista blanca i executa-la"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Continuar mentre s'executa",
|
||||
"tooltip": "Continua malgrat els advertiments"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Befehl ausführen",
|
||||
"tooltip": "Diesen Befehl ausführen"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Hinzufügen & Ausführen",
|
||||
"tooltip": "Befehlsmuster zur Whitelist hinzufügen und ausführen"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Während Ausführung fortfahren",
|
||||
"tooltip": "Trotz Warnungen fortfahren"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,10 @@
|
|||
"title": "Run Command",
|
||||
"tooltip": "Execute this command"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Add & Run",
|
||||
"tooltip": "Add command pattern to whitelist and run"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Proceed While Running",
|
||||
"tooltip": "Continue despite warnings"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Ejecutar comando",
|
||||
"tooltip": "Ejecutar este comando"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Añadir y ejecutar",
|
||||
"tooltip": "Añadir patrón de comando a la lista blanca y ejecutar"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Continuar mientras se ejecuta",
|
||||
"tooltip": "Continuar a pesar de las advertencias"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Exécuter la commande",
|
||||
"tooltip": "Exécuter cette commande"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Ajouter et exécuter",
|
||||
"tooltip": "Ajouter le modèle de commande à la liste blanche et exécuter"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Continuer pendant l'exécution",
|
||||
"tooltip": "Continuer malgré les avertissements"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "कमांड चलाएँ",
|
||||
"tooltip": "इस कमांड को निष्पादित करें"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "जोड़ें और चलाएं",
|
||||
"tooltip": "कमांड पैटर्न को व्हाइटलिस्ट में जोड़ें और चलाएं"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "चलते समय आगे बढ़ें",
|
||||
"tooltip": "चेतावनियों के बावजूद जारी रखें"
|
||||
|
|
|
|||
|
|
@ -73,6 +73,10 @@
|
|||
"title": "Jalankan Perintah",
|
||||
"tooltip": "Eksekusi perintah ini"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Tambah & Jalankan",
|
||||
"tooltip": "Tambahkan pola perintah ke daftar putih dan jalankan"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Lanjutkan Saat Berjalan",
|
||||
"tooltip": "Lanjutkan meskipun ada peringatan"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Esegui comando",
|
||||
"tooltip": "Esegui questo comando"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Aggiungi ed esegui",
|
||||
"tooltip": "Aggiungi il pattern del comando alla whitelist ed eseguilo"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Procedi durante l'esecuzione",
|
||||
"tooltip": "Continua nonostante gli avvisi"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "コマンド実行",
|
||||
"tooltip": "このコマンドを実行"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "追加して実行",
|
||||
"tooltip": "コマンドパターンをホワイトリストに追加して実行します"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "実行中も続行",
|
||||
"tooltip": "警告にもかかわらず続行"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "명령 실행",
|
||||
"tooltip": "이 명령 실행"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "추가 및 실행",
|
||||
"tooltip": "허용 목록에 명령 패턴을 추가하고 실행합니다."
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "실행 중에도 계속",
|
||||
"tooltip": "경고에도 불구하고 계속 진행"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Commando uitvoeren",
|
||||
"tooltip": "Voer dit commando uit"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Toevoegen & uitvoeren",
|
||||
"tooltip": "Commandopatroon toevoegen aan de witte lijst en uitvoeren"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Doorgaan tijdens uitvoeren",
|
||||
"tooltip": "Ga door ondanks waarschuwingen"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Uruchom polecenie",
|
||||
"tooltip": "Wykonaj to polecenie"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Dodaj i uruchom",
|
||||
"tooltip": "Dodaj wzorzec polecenia do białej listy i uruchom"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Kontynuuj podczas wykonywania",
|
||||
"tooltip": "Kontynuuj pomimo ostrzeżeń"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Executar comando",
|
||||
"tooltip": "Executar este comando"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Adicionar & Executar",
|
||||
"tooltip": "Adicionar padrão de comando à lista de permissões e executar"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Prosseguir durante execução",
|
||||
"tooltip": "Continuar apesar dos avisos"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Выполнить команду",
|
||||
"tooltip": "Выполнить эту команду"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Добавить и выполнить",
|
||||
"tooltip": "Добавить шаблон команды в белый список и выполнить"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Продолжить во время выполнения",
|
||||
"tooltip": "Продолжить несмотря на предупреждения"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Komutu Çalıştır",
|
||||
"tooltip": "Bu komutu çalıştır"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Ekle ve Çalıştır",
|
||||
"tooltip": "Komut desenini beyaz listeye ekle ve çalıştır"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Çalışırken Devam Et",
|
||||
"tooltip": "Uyarılara rağmen devam et"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "Chạy lệnh",
|
||||
"tooltip": "Thực thi lệnh này"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "Thêm & Chạy",
|
||||
"tooltip": "Thêm mẫu lệnh vào danh sách trắng và chạy"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "Tiếp tục trong khi chạy",
|
||||
"tooltip": "Tiếp tục bất chấp cảnh báo"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "运行命令",
|
||||
"tooltip": "执行此命令"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "添加并运行",
|
||||
"tooltip": "将命令模式添加到白名单并运行"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "强制继续",
|
||||
"tooltip": "忽略运行中的命令并继续"
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@
|
|||
"title": "執行命令",
|
||||
"tooltip": "執行此命令"
|
||||
},
|
||||
"addAndRunCommand": {
|
||||
"title": "新增並執行",
|
||||
"tooltip": "將命令模式新增至白名單並執行"
|
||||
},
|
||||
"proceedWhileRunning": {
|
||||
"title": "執行時繼續",
|
||||
"tooltip": "儘管有警告仍繼續執行"
|
||||
|
|
|
|||
201
webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts
Normal file
201
webview-ui/src/utils/__tests__/extract-command-pattern.spec.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { describe, it, expect } from "vitest"
|
||||
import { extractCommandPattern, getPatternDescription } from "../extract-command-pattern"
|
||||
|
||||
describe("extractCommandPattern", () => {
|
||||
it("handles empty or null input", () => {
|
||||
expect(extractCommandPattern("")).toBe("")
|
||||
expect(extractCommandPattern(" ")).toBe("")
|
||||
expect(extractCommandPattern(null as any)).toBe("")
|
||||
expect(extractCommandPattern(undefined as any)).toBe("")
|
||||
})
|
||||
|
||||
describe("npm/yarn/pnpm/bun commands", () => {
|
||||
it("extracts npm run patterns", () => {
|
||||
expect(extractCommandPattern("npm run build")).toBe("npm run")
|
||||
expect(extractCommandPattern("npm run test:unit")).toBe("npm run *")
|
||||
expect(extractCommandPattern("yarn run dev")).toBe("yarn run")
|
||||
expect(extractCommandPattern("pnpm run lint")).toBe("pnpm run")
|
||||
expect(extractCommandPattern("bun run start")).toBe("bun run")
|
||||
})
|
||||
|
||||
it("extracts npm script patterns", () => {
|
||||
expect(extractCommandPattern("npm test")).toBe("npm test")
|
||||
expect(extractCommandPattern("npm build")).toBe("npm build")
|
||||
expect(extractCommandPattern("npm start")).toBe("npm start")
|
||||
expect(extractCommandPattern("yarn test")).toBe("yarn test")
|
||||
expect(extractCommandPattern("pnpm build")).toBe("pnpm build")
|
||||
})
|
||||
|
||||
it("handles npm with flags", () => {
|
||||
expect(extractCommandPattern("npm install --save-dev")).toBe("npm install")
|
||||
expect(extractCommandPattern("npm test -- --coverage")).toBe("npm test")
|
||||
expect(extractCommandPattern("npm -v")).toBe("npm")
|
||||
})
|
||||
})
|
||||
|
||||
describe("git commands", () => {
|
||||
it("extracts git subcommands", () => {
|
||||
expect(extractCommandPattern("git commit -m 'message'")).toBe("git commit")
|
||||
expect(extractCommandPattern("git push origin main")).toBe("git push")
|
||||
expect(extractCommandPattern("git pull --rebase")).toBe("git pull")
|
||||
expect(extractCommandPattern("git checkout -b feature")).toBe("git checkout")
|
||||
})
|
||||
|
||||
it("handles git with flags only", () => {
|
||||
expect(extractCommandPattern("git --version")).toBe("git")
|
||||
})
|
||||
})
|
||||
|
||||
describe("script files", () => {
|
||||
it("preserves full script paths", () => {
|
||||
expect(extractCommandPattern("./scripts/deploy.sh production")).toBe("./scripts/deploy.sh")
|
||||
expect(extractCommandPattern("/usr/local/bin/backup.sh")).toBe("/usr/local/bin/backup.sh")
|
||||
expect(extractCommandPattern("scripts/test.py --verbose")).toBe("scripts/test.py")
|
||||
expect(extractCommandPattern("./build.js --watch")).toBe("./build.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("interpreters", () => {
|
||||
it("extracts just the interpreter", () => {
|
||||
expect(extractCommandPattern("python script.py --arg value")).toBe("python")
|
||||
expect(extractCommandPattern("python3 -m pytest")).toBe("python3")
|
||||
expect(extractCommandPattern("node index.js --port 3000")).toBe("node")
|
||||
expect(extractCommandPattern("ruby app.rb")).toBe("ruby")
|
||||
expect(extractCommandPattern("java -jar app.jar")).toBe("java")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dangerous commands", () => {
|
||||
it("extracts just the base command", () => {
|
||||
expect(extractCommandPattern("rm -rf node_modules")).toBe("rm")
|
||||
expect(extractCommandPattern("mv old.txt new.txt")).toBe("mv")
|
||||
expect(extractCommandPattern("chmod 755 script.sh")).toBe("chmod")
|
||||
expect(extractCommandPattern("find . -name '*.log' -delete")).toBe("find")
|
||||
})
|
||||
})
|
||||
|
||||
describe("chained commands", () => {
|
||||
it("extracts patterns from all commands in chain", () => {
|
||||
expect(extractCommandPattern("cd /path && npm install")).toBe("cd * && npm install")
|
||||
expect(extractCommandPattern("npm test || echo 'failed'")).toBe("npm test || echo")
|
||||
expect(extractCommandPattern("git pull; npm install; npm run build")).toBe(
|
||||
"git pull ; npm install ; npm run",
|
||||
)
|
||||
expect(extractCommandPattern("echo 'start' | grep start")).toBe("echo | grep")
|
||||
})
|
||||
|
||||
it("handles complex chained commands with wildcards", () => {
|
||||
expect(extractCommandPattern("cd /path/to/project && npm run build:prod --verbose")).toBe(
|
||||
"cd * && npm run *",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("docker/kubectl commands", () => {
|
||||
it("extracts docker subcommands", () => {
|
||||
expect(extractCommandPattern("docker run -it ubuntu")).toBe("docker run")
|
||||
expect(extractCommandPattern("docker build -t myapp .")).toBe("docker build")
|
||||
expect(extractCommandPattern("kubectl get pods")).toBe("kubectl get")
|
||||
expect(extractCommandPattern("kubectl apply -f config.yaml")).toBe("kubectl apply")
|
||||
expect(extractCommandPattern("helm install myapp ./chart")).toBe("helm install")
|
||||
})
|
||||
})
|
||||
|
||||
describe("make commands", () => {
|
||||
it("extracts make targets", () => {
|
||||
expect(extractCommandPattern("make build")).toBe("make build")
|
||||
expect(extractCommandPattern("make test")).toBe("make test")
|
||||
expect(extractCommandPattern("make clean install")).toBe("make clean")
|
||||
expect(extractCommandPattern("make -j4")).toBe("make")
|
||||
})
|
||||
})
|
||||
|
||||
describe("quoted arguments", () => {
|
||||
it("handles single quotes", () => {
|
||||
expect(extractCommandPattern("echo 'hello world'")).toBe("echo")
|
||||
expect(extractCommandPattern("git commit -m 'feat: add feature'")).toBe("git commit")
|
||||
})
|
||||
|
||||
it("handles double quotes", () => {
|
||||
expect(extractCommandPattern('echo "hello world"')).toBe("echo")
|
||||
expect(extractCommandPattern('npm run "test:unit"')).toBe("npm run *")
|
||||
})
|
||||
|
||||
it("handles quotes with spaces", () => {
|
||||
expect(extractCommandPattern('git commit -m "fix: resolve issue #123"')).toBe("git commit")
|
||||
expect(extractCommandPattern("echo 'multiple spaces'")).toBe("echo")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles commands with redirects", () => {
|
||||
expect(extractCommandPattern("npm test > output.log")).toBe("npm test")
|
||||
expect(extractCommandPattern("echo hello 2>&1")).toBe("echo")
|
||||
})
|
||||
|
||||
it("handles cd command", () => {
|
||||
expect(extractCommandPattern("cd /home/user/project")).toBe("cd *")
|
||||
expect(extractCommandPattern("cd ..")).toBe("cd *")
|
||||
expect(extractCommandPattern("cd")).toBe("cd *")
|
||||
})
|
||||
|
||||
it("handles commands with environment variables", () => {
|
||||
expect(extractCommandPattern("NODE_ENV=production npm start")).toBe("NODE_ENV=production")
|
||||
expect(extractCommandPattern("PORT=3000 node server.js")).toBe("PORT=3000")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPatternDescription", () => {
|
||||
it("describes npm patterns", () => {
|
||||
expect(getPatternDescription("npm run")).toBe("npm run scripts")
|
||||
expect(getPatternDescription("npm test")).toBe("npm test commands")
|
||||
expect(getPatternDescription("npm")).toBe("npm commands")
|
||||
expect(getPatternDescription("yarn run")).toBe("yarn run scripts")
|
||||
expect(getPatternDescription("pnpm build")).toBe("pnpm build commands")
|
||||
})
|
||||
|
||||
it("describes git patterns", () => {
|
||||
expect(getPatternDescription("git commit")).toBe("git commit commands")
|
||||
expect(getPatternDescription("git push")).toBe("git push commands")
|
||||
expect(getPatternDescription("git")).toBe("git commands")
|
||||
})
|
||||
|
||||
it("describes script patterns", () => {
|
||||
expect(getPatternDescription("./scripts/deploy.sh")).toBe("this specific script")
|
||||
expect(getPatternDescription("/usr/bin/backup.py")).toBe("this specific script")
|
||||
})
|
||||
|
||||
it("describes interpreter patterns", () => {
|
||||
expect(getPatternDescription("python")).toBe("python scripts")
|
||||
expect(getPatternDescription("node")).toBe("node scripts")
|
||||
expect(getPatternDescription("ruby")).toBe("ruby scripts")
|
||||
})
|
||||
|
||||
it("describes docker/kubectl patterns", () => {
|
||||
expect(getPatternDescription("docker run")).toBe("docker run commands")
|
||||
expect(getPatternDescription("kubectl get")).toBe("kubectl get commands")
|
||||
expect(getPatternDescription("helm install")).toBe("helm install commands")
|
||||
})
|
||||
|
||||
it("describes make patterns", () => {
|
||||
expect(getPatternDescription("make build")).toBe("make build target")
|
||||
expect(getPatternDescription("make test")).toBe("make test target")
|
||||
expect(getPatternDescription("make")).toBe("make commands")
|
||||
})
|
||||
|
||||
it("describes cd pattern", () => {
|
||||
expect(getPatternDescription("cd")).toBe("directory navigation")
|
||||
})
|
||||
|
||||
it("describes generic patterns", () => {
|
||||
expect(getPatternDescription("echo")).toBe("echo commands")
|
||||
expect(getPatternDescription("rm")).toBe("rm commands")
|
||||
expect(getPatternDescription("custom-tool")).toBe("custom-tool commands")
|
||||
})
|
||||
|
||||
it("handles empty input", () => {
|
||||
expect(getPatternDescription("")).toBe("")
|
||||
expect(getPatternDescription(null as any)).toBe("")
|
||||
})
|
||||
})
|
||||
232
webview-ui/src/utils/extract-command-pattern.ts
Normal file
232
webview-ui/src/utils/extract-command-pattern.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Extracts a generalizable command pattern from a specific command.
|
||||
* This function creates patterns that can be used for whitelisting similar commands.
|
||||
*
|
||||
* Examples:
|
||||
* - "npm test" -> "npm test"
|
||||
* - "npm run build" -> "npm run"
|
||||
* - "git commit -m 'message'" -> "git commit"
|
||||
* - "echo 'hello world'" -> "echo"
|
||||
* - "python script.py --arg value" -> "python"
|
||||
* - "./scripts/deploy.sh production" -> "./scripts/deploy.sh"
|
||||
* - "cd /path/to/dir && npm install" -> "cd * && npm install"
|
||||
* - "rm -rf node_modules" -> "rm"
|
||||
*/
|
||||
export function extractCommandPattern(command: string): string {
|
||||
if (!command?.trim()) return ""
|
||||
|
||||
// Remove leading/trailing whitespace
|
||||
const trimmedCommand = command.trim()
|
||||
|
||||
// Check if this is a chained command
|
||||
const chainMatch = trimmedCommand.match(/^(.+?)\s*(&&|\|\||;|\|)\s*(.+)$/)
|
||||
if (chainMatch) {
|
||||
// Handle chained commands by processing each part
|
||||
const [, firstPart, operator, restPart] = chainMatch
|
||||
const firstPattern = extractSingleCommandPattern(firstPart.trim())
|
||||
const restPattern = extractCommandPattern(restPart.trim())
|
||||
return `${firstPattern} ${operator} ${restPattern}`
|
||||
}
|
||||
|
||||
// Not a chained command, process normally
|
||||
return extractSingleCommandPattern(trimmedCommand)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts pattern from a single command (not chained)
|
||||
*/
|
||||
function extractSingleCommandPattern(command: string): string {
|
||||
const firstCommand = command
|
||||
|
||||
// Split the command into tokens, respecting quotes
|
||||
const tokens: string[] = []
|
||||
let currentToken = ""
|
||||
let inSingleQuote = false
|
||||
let inDoubleQuote = false
|
||||
let escapeNext = false
|
||||
|
||||
for (let i = 0; i < firstCommand.length; i++) {
|
||||
const char = firstCommand[i]
|
||||
|
||||
if (escapeNext) {
|
||||
currentToken += char
|
||||
escapeNext = false
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
escapeNext = true
|
||||
currentToken += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote
|
||||
currentToken += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
currentToken += char
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === " " && !inSingleQuote && !inDoubleQuote) {
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken)
|
||||
currentToken = ""
|
||||
}
|
||||
} else {
|
||||
currentToken += char
|
||||
}
|
||||
}
|
||||
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken)
|
||||
}
|
||||
|
||||
if (tokens.length === 0) return ""
|
||||
|
||||
const baseCommand = tokens[0]
|
||||
|
||||
// Special handling for common patterns
|
||||
|
||||
// 1. npm/yarn/pnpm commands - include subcommand with wildcards for scripts
|
||||
if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand) && tokens.length > 1) {
|
||||
const subCommand = tokens[1]
|
||||
// For "run" commands, include "run" with wildcard for script names
|
||||
if (subCommand === "run" && tokens.length > 2) {
|
||||
// Check if the script name contains special characters like colons
|
||||
const scriptName = tokens[2]
|
||||
if (scriptName && (scriptName.includes(":") || scriptName.includes("-"))) {
|
||||
return `${baseCommand} run *`
|
||||
}
|
||||
return `${baseCommand} run`
|
||||
}
|
||||
// For direct scripts like "npm test", "npm build", include the script name
|
||||
if (!subCommand.startsWith("-")) {
|
||||
return `${baseCommand} ${subCommand}`
|
||||
}
|
||||
}
|
||||
|
||||
// 2. git commands - include subcommand
|
||||
if (baseCommand === "git" && tokens.length > 1) {
|
||||
const subCommand = tokens[1]
|
||||
if (!subCommand.startsWith("-")) {
|
||||
return `${baseCommand} ${subCommand}`
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Script files - include the full script path
|
||||
if (
|
||||
baseCommand.includes("/") ||
|
||||
baseCommand.endsWith(".sh") ||
|
||||
baseCommand.endsWith(".py") ||
|
||||
baseCommand.endsWith(".js") ||
|
||||
baseCommand.endsWith(".rb")
|
||||
) {
|
||||
return baseCommand
|
||||
}
|
||||
|
||||
// 4. Python/node/ruby/etc interpreters - just the interpreter
|
||||
if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) {
|
||||
return baseCommand
|
||||
}
|
||||
|
||||
// 5. Common shell commands with dangerous flags - just the command
|
||||
if (["rm", "mv", "cp", "chmod", "chown", "find", "grep", "sed", "awk"].includes(baseCommand)) {
|
||||
return baseCommand
|
||||
}
|
||||
|
||||
// 6. cd command - include wildcard for paths
|
||||
if (baseCommand === "cd") {
|
||||
return "cd *"
|
||||
}
|
||||
|
||||
// 7. Docker/kubectl commands - include subcommand
|
||||
if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens.length > 1) {
|
||||
const subCommand = tokens[1]
|
||||
if (!subCommand.startsWith("-")) {
|
||||
return `${baseCommand} ${subCommand}`
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Make commands - include target if present
|
||||
if (baseCommand === "make" && tokens.length > 1) {
|
||||
const target = tokens[1]
|
||||
if (!target.startsWith("-")) {
|
||||
return `${baseCommand} ${target}`
|
||||
}
|
||||
}
|
||||
|
||||
// Default: just return the base command
|
||||
return baseCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable description of what the pattern will allow
|
||||
*
|
||||
* Examples:
|
||||
* - "npm test" -> "npm test commands"
|
||||
* - "npm run" -> "npm run scripts"
|
||||
* - "git commit" -> "git commit commands"
|
||||
* - "python" -> "python scripts"
|
||||
* - "./scripts/deploy.sh" -> "this specific script"
|
||||
*/
|
||||
export function getPatternDescription(pattern: string): string {
|
||||
if (!pattern) return ""
|
||||
|
||||
const tokens = pattern.split(" ")
|
||||
const baseCommand = tokens[0]
|
||||
|
||||
// npm/yarn/pnpm patterns
|
||||
if (["npm", "yarn", "pnpm", "bun"].includes(baseCommand)) {
|
||||
if (tokens[1] === "run") {
|
||||
return `${baseCommand} run scripts`
|
||||
}
|
||||
if (tokens[1]) {
|
||||
return `${baseCommand} ${tokens[1]} commands`
|
||||
}
|
||||
return `${baseCommand} commands`
|
||||
}
|
||||
|
||||
// git patterns
|
||||
if (baseCommand === "git" && tokens[1]) {
|
||||
return `git ${tokens[1]} commands`
|
||||
}
|
||||
|
||||
// Script files
|
||||
if (
|
||||
baseCommand.includes("/") ||
|
||||
baseCommand.endsWith(".sh") ||
|
||||
baseCommand.endsWith(".py") ||
|
||||
baseCommand.endsWith(".js") ||
|
||||
baseCommand.endsWith(".rb")
|
||||
) {
|
||||
return "this specific script"
|
||||
}
|
||||
|
||||
// Interpreters
|
||||
if (["python", "python3", "node", "ruby", "perl", "php", "java", "go"].includes(baseCommand)) {
|
||||
return `${baseCommand} scripts`
|
||||
}
|
||||
|
||||
// Docker/kubectl
|
||||
if (["docker", "kubectl", "helm"].includes(baseCommand) && tokens[1]) {
|
||||
return `${baseCommand} ${tokens[1]} commands`
|
||||
}
|
||||
|
||||
// Make
|
||||
if (baseCommand === "make" && tokens[1]) {
|
||||
return `make ${tokens[1]} target`
|
||||
}
|
||||
|
||||
// cd
|
||||
if (baseCommand === "cd") {
|
||||
return "directory navigation"
|
||||
}
|
||||
|
||||
// Default
|
||||
return `${baseCommand} commands`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue