Expose a list of allowed auto-execute commands (#31)

This commit is contained in:
Matt Rubens
2024-12-01 15:34:36 -05:00
committed by GitHub
parent 750c24c8a7
commit 6b8f9f7a45
14 changed files with 1085 additions and 719 deletions

View File

@@ -1,10 +1,10 @@
# Change Log
## Roo Cline 2.1.4
## Roo Cline 2.1.8
- Roo Cline now publishes to the VS Code Marketplace!
- Roo Cline now allows browser actions without approval when `alwaysAllowBrowser` is true
- Roo Cline now can run side-by-side with Cline
- Roo Cline now allows configuration of allowed commands without approval
## [2.1.6]

View File

@@ -3,11 +3,7 @@
"displayName": "Roo Cline",
"description": "A fork of Cline, an autonomous coding agent, with some added experimental configuration and automation features.",
"publisher": "RooVeterinaryInc",
"version": "2.1.7",
"files": [
"bin/roo-cline-2.1.7.vsix",
"assets/icons/rocket.png"
],
"version": "2.1.8",
"icon": "assets/icons/rocket.png",
"galleryBanner": {
"color": "#617A91",
@@ -116,6 +112,26 @@
"when": "view == roo-cline.SidebarProvider"
}
]
},
"configuration": {
"title": "RooCline",
"properties": {
"roo-cline.allowedCommands": {
"type": "array",
"items": {
"type": "string"
},
"default": [
"npm test",
"npm install",
"tsc",
"git log",
"git diff",
"git show"
],
"description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled"
}
}
}
},
"scripts": {

View File

@@ -60,6 +60,7 @@ type GlobalStateKey =
| "azureApiVersion"
| "openRouterModelId"
| "openRouterModelInfo"
| "allowedCommands"
export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json",
@@ -510,6 +511,13 @@ export class ClineProvider implements vscode.WebviewViewProvider {
}
break
case "allowedCommands":
await this.context.globalState.update('allowedCommands', message.commands);
// Also update workspace settings
await vscode.workspace
.getConfiguration('roo-cline')
.update('allowedCommands', message.commands, vscode.ConfigurationTarget.Global);
break;
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
}
@@ -820,6 +828,10 @@ export class ClineProvider implements vscode.WebviewViewProvider {
taskHistory,
} = await this.getState()
const allowedCommands = vscode.workspace
.getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || []
return {
version: this.context.extension?.packageJSON?.version ?? "",
apiConfiguration,
@@ -834,6 +846,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
.filter((item) => item.ts && item.task)
.sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
allowedCommands,
}
}
@@ -921,6 +934,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowExecute,
alwaysAllowBrowser,
taskHistory,
allowedCommands,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -953,6 +967,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("alwaysAllowExecute") as Promise<boolean | undefined>,
this.getGlobalState("alwaysAllowBrowser") as Promise<boolean | undefined>,
this.getGlobalState("taskHistory") as Promise<HistoryItem[] | undefined>,
this.getGlobalState("allowedCommands") as Promise<string[] | undefined>,
])
let apiProvider: ApiProvider
@@ -1003,6 +1018,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
alwaysAllowExecute: alwaysAllowExecute ?? false,
alwaysAllowBrowser: alwaysAllowBrowser ?? false,
taskHistory,
allowedCommands,
}
}

View File

@@ -26,6 +26,16 @@ export function activate(context: vscode.ExtensionContext) {
outputChannel.appendLine("Cline extension activated")
// Get default commands from configuration
const defaultCommands = vscode.workspace
.getConfiguration('roo-cline')
.get<string[]>('allowedCommands') || [];
// Initialize global state if not already set
if (!context.globalState.get('allowedCommands')) {
context.globalState.update('allowedCommands', defaultCommands);
}
const sidebarProvider = new ClineProvider(context, outputChannel)
context.subscriptions.push(

View File

@@ -30,6 +30,9 @@ export interface ExtensionMessage {
export interface ExtensionState {
version: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
apiConfiguration?: ApiConfiguration
customInstructions?: string
alwaysAllowReadOnly?: boolean
@@ -37,9 +40,7 @@ export interface ExtensionState {
alwaysAllowExecute?: boolean
alwaysAllowBrowser?: boolean
uriScheme?: string
clineMessages: ClineMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
allowedCommands?: string[]
}
export interface ClineMessage {

View File

@@ -4,6 +4,7 @@ export interface WebviewMessage {
type:
| "apiConfiguration"
| "customInstructions"
| "allowedCommands"
| "alwaysAllowReadOnly"
| "alwaysAllowWrite"
| "alwaysAllowExecute"
@@ -31,6 +32,7 @@ export interface WebviewMessage {
apiConfiguration?: ApiConfiguration
images?: string[]
bool?: boolean
commands?: string[]
}
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"

View File

@@ -33,6 +33,7 @@
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@babel/plugin-transform-private-property-in-object": "^7.25.9",
"@types/vscode-webview": "^1.57.5"
}
},
@@ -68,12 +69,12 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
"license": "MIT",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
"integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==",
"dependencies": {
"@babel/highlight": "^7.24.7",
"@babel/helper-validator-identifier": "^7.25.9",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
@@ -165,27 +166,26 @@
}
},
"node_modules/@babel/generator": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz",
"integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==",
"license": "MIT",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz",
"integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==",
"dependencies": {
"@babel/types": "^7.24.7",
"@babel/parser": "^7.26.2",
"@babel/types": "^7.26.0",
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^2.5.1"
"jsesc": "^3.0.2"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-annotate-as-pure": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz",
"integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz",
"integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==",
"dependencies": {
"@babel/types": "^7.24.7"
"@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -230,19 +230,16 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz",
"integrity": "sha512-kTkaDl7c9vO80zeX1rJxnuRpEsD5tA81yh11X1gQo+PhSti3JS+7qeZo9U4RHobKRiFPKaGK3svUAeb8D0Q7eg==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz",
"integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.24.7",
"@babel/helper-environment-visitor": "^7.24.7",
"@babel/helper-function-name": "^7.24.7",
"@babel/helper-member-expression-to-functions": "^7.24.7",
"@babel/helper-optimise-call-expression": "^7.24.7",
"@babel/helper-replace-supers": "^7.24.7",
"@babel/helper-skip-transparent-expression-wrappers": "^7.24.7",
"@babel/helper-split-export-declaration": "^7.24.7",
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/helper-replace-supers": "^7.25.9",
"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
"@babel/traverse": "^7.25.9",
"semver": "^6.3.1"
},
"engines": {
@@ -341,13 +338,12 @@
}
},
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz",
"integrity": "sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz",
"integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==",
"dependencies": {
"@babel/traverse": "^7.24.7",
"@babel/types": "^7.24.7"
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -386,22 +382,20 @@
}
},
"node_modules/@babel/helper-optimise-call-expression": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz",
"integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz",
"integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==",
"dependencies": {
"@babel/types": "^7.24.7"
"@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-plugin-utils": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz",
"integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz",
"integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==",
"engines": {
"node": ">=6.9.0"
}
@@ -424,14 +418,13 @@
}
},
"node_modules/@babel/helper-replace-supers": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz",
"integrity": "sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz",
"integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==",
"dependencies": {
"@babel/helper-environment-visitor": "^7.24.7",
"@babel/helper-member-expression-to-functions": "^7.24.7",
"@babel/helper-optimise-call-expression": "^7.24.7"
"@babel/helper-member-expression-to-functions": "^7.25.9",
"@babel/helper-optimise-call-expression": "^7.25.9",
"@babel/traverse": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -454,13 +447,12 @@
}
},
"node_modules/@babel/helper-skip-transparent-expression-wrappers": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz",
"integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz",
"integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==",
"dependencies": {
"@babel/traverse": "^7.24.7",
"@babel/types": "^7.24.7"
"@babel/traverse": "^7.25.9",
"@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -479,19 +471,17 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz",
"integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz",
"integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==",
"engines": {
"node": ">=6.9.0"
}
@@ -533,26 +523,13 @@
"node": ">=6.9.0"
}
},
"node_modules/@babel/highlight": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.7",
"chalk": "^2.4.2",
"js-tokens": "^4.0.0",
"picocolors": "^1.0.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz",
"integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==",
"license": "MIT",
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz",
"integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==",
"dependencies": {
"@babel/types": "^7.26.0"
},
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -727,18 +704,6 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-proposal-private-property-in-object": {
"version": "7.21.0-placeholder-for-preset-env.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/plugin-syntax-async-generators": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
@@ -1631,15 +1596,13 @@
}
},
"node_modules/@babel/plugin-transform-private-property-in-object": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz",
"integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz",
"integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==",
"dependencies": {
"@babel/helper-annotate-as-pure": "^7.24.7",
"@babel/helper-create-class-features-plugin": "^7.24.7",
"@babel/helper-plugin-utils": "^7.24.7",
"@babel/plugin-syntax-private-property-in-object": "^7.14.5"
"@babel/helper-annotate-as-pure": "^7.25.9",
"@babel/helper-create-class-features-plugin": "^7.25.9",
"@babel/helper-plugin-utils": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -2055,6 +2018,17 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": {
"version": "7.21.0-placeholder-for-preset-env.2",
"resolved": "https://roo-815250993495.d.codeartifact.us-east-1.amazonaws.com/npm/roo-dev/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
"engines": {
"node": ">=6.9.0"
},
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/preset-env/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -2136,33 +2110,28 @@
}
},
"node_modules/@babel/template": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
"integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz",
"integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==",
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/parser": "^7.24.7",
"@babel/types": "^7.24.7"
"@babel/code-frame": "^7.25.9",
"@babel/parser": "^7.25.9",
"@babel/types": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz",
"integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==",
"license": "MIT",
"version": "7.25.9",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz",
"integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==",
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.24.7",
"@babel/helper-environment-visitor": "^7.24.7",
"@babel/helper-function-name": "^7.24.7",
"@babel/helper-hoist-variables": "^7.24.7",
"@babel/helper-split-export-declaration": "^7.24.7",
"@babel/parser": "^7.24.7",
"@babel/types": "^7.24.7",
"@babel/code-frame": "^7.25.9",
"@babel/generator": "^7.25.9",
"@babel/parser": "^7.25.9",
"@babel/template": "^7.25.9",
"@babel/types": "^7.25.9",
"debug": "^4.3.1",
"globals": "^11.1.0"
},
@@ -2171,14 +2140,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz",
"integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==",
"license": "MIT",
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz",
"integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==",
"dependencies": {
"@babel/helper-string-parser": "^7.24.7",
"@babel/helper-validator-identifier": "^7.24.7",
"to-fast-properties": "^2.0.0"
"@babel/helper-string-parser": "^7.25.9",
"@babel/helper-validator-identifier": "^7.25.9"
},
"engines": {
"node": ">=6.9.0"
@@ -13354,15 +13321,14 @@
}
},
"node_modules/jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
"license": "MIT",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
"integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
"bin": {
"jsesc": "bin/jsesc"
},
"engines": {
"node": ">=4"
"node": ">=6"
}
},
"node_modules/json-buffer": {
@@ -19238,15 +19204,6 @@
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"license": "BSD-3-Clause"
},
"node_modules/to-fast-properties": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",

View File

@@ -52,6 +52,15 @@
]
},
"devDependencies": {
"@babel/plugin-transform-private-property-in-object": "^7.25.9",
"@types/vscode-webview": "^1.57.5"
},
"jest": {
"transformIgnorePatterns": [
"/node_modules/(?!(rehype-highlight|react-remark|unist-util-visit|vfile|unified|bail|is-plain-obj|trough|vfile-message|unist-util-stringify-position|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|character-entities|markdown-table|zwitch|longest-streak|escape-string-regexp|unist-util-is|hast-util-to-text)/)"
],
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}

View File

@@ -34,18 +34,8 @@ interface ChatViewProps {
export const MAX_IMAGES_PER_MESSAGE = 20 // Anthropic limits to 20 images
const ALLOWED_AUTO_EXECUTE_COMMANDS = [
'npm',
'npx',
'tsc',
'git log',
'git diff',
'git show',
'ls'
] as const
const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryView }: ChatViewProps) => {
const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute } = useExtensionState()
const { version, clineMessages: messages, taskHistory, apiConfiguration, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, allowedCommands } = useExtensionState()
//const task = messages.length > 0 ? (messages[0].say === "task" ? messages[0] : undefined) : undefined) : undefined
const task = useMemo(() => messages.at(0), [messages]) // leaving this less safe version here since if the first message is not a task, then the extension is in a bad state and needs to be debugged (see Cline.abort)
@@ -712,19 +702,14 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
if (lastMessage?.type === "ask" && lastMessage.text) {
const command = lastMessage.text
// Check for command chaining characters
if (command.includes('&&') ||
command.includes(';') ||
command.includes('||') ||
command.includes('|') ||
command.includes('$(') ||
command.includes('`')) {
return false
}
const trimmedCommand = command.trim().toLowerCase()
return ALLOWED_AUTO_EXECUTE_COMMANDS.some(prefix =>
trimmedCommand.startsWith(prefix.toLowerCase())
)
// Split command by chaining operators
const commands = command.split(/&&|\|\||;|\||\$\(|`/).map(cmd => cmd.trim())
// Check if all individual commands are allowed
return commands.every((cmd) => {
const trimmedCommand = cmd.toLowerCase()
return allowedCommands?.some((prefix) => trimmedCommand.startsWith(prefix.toLowerCase()))
})
}
return false
}
@@ -737,7 +722,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
) {
handlePrimaryButtonClick()
}
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages])
}, [clineAsk, enableButtons, handlePrimaryButtonClick, alwaysAllowBrowser, alwaysAllowReadOnly, alwaysAllowWrite, alwaysAllowExecute, messages, allowedCommands])
return (
<div

View File

@@ -1,309 +1,549 @@
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
import React from 'react'
import { render, waitFor } from '@testing-library/react'
import ChatView from '../ChatView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
// Mock vscode
// Define minimal types needed for testing
interface ClineMessage {
type: 'say' | 'ask';
say?: string;
ask?: string;
ts: number;
text?: string;
partial?: boolean;
}
interface ExtensionState {
version: string;
clineMessages: ClineMessage[];
taskHistory: any[];
shouldShowAnnouncement: boolean;
allowedCommands: string[];
alwaysAllowExecute: boolean;
[key: string]: any;
}
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
}))
// Mock all components that use problematic dependencies
jest.mock('../../common/CodeBlock', () => ({
__esModule: true,
default: () => <div data-testid="mock-code-block" />
}))
jest.mock('../../common/MarkdownBlock', () => ({
__esModule: true,
default: () => <div data-testid="mock-markdown-block" />
vscode: {
postMessage: jest.fn(),
},
}))
// Mock components that use ESM dependencies
jest.mock('../BrowserSessionRow', () => ({
__esModule: true,
default: () => <div data-testid="mock-browser-session-row" />
__esModule: true,
default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) {
return <div data-testid="browser-session">{JSON.stringify(messages)}</div>
}
}))
// Update ChatRow mock to capture props
let chatRowProps = null
jest.mock('../ChatRow', () => ({
__esModule: true,
default: function MockChatRow({ message }: { message: ClineMessage }) {
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
}
}))
interface ChatTextAreaProps {
onSend: (value: string) => void;
inputValue?: string;
textAreaDisabled?: boolean;
placeholderText?: string;
selectedImages?: string[];
shouldDisableImages?: boolean;
}
jest.mock('../ChatTextArea', () => {
const mockReact = require('react')
return {
__esModule: true,
default: (props: any) => {
chatRowProps = props
return <div data-testid="mock-chat-row" />
}
default: mockReact.forwardRef(function MockChatTextArea(props: ChatTextAreaProps, ref: React.ForwardedRef<HTMLInputElement>) {
return (
<div data-testid="chat-textarea">
<input ref={ref} type="text" onChange={(e) => props.onSend(e.target.value)} />
</div>
)
})
}
})
jest.mock('../TaskHeader', () => ({
__esModule: true,
default: function MockTaskHeader({ task }: { task: ClineMessage }) {
return <div data-testid="task-header">{JSON.stringify(task)}</div>
}
}))
// Mock Virtuoso component
jest.mock('react-virtuoso', () => ({
Virtuoso: ({ children }: any) => (
<div data-testid="mock-virtuoso">{children}</div>
)
}))
// Mock VS Code components
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
VSCodeProgressRing: () => <div data-testid="progress-ring" />
VSCodeButton: function MockVSCodeButton({
children,
onClick,
appearance
}: {
children: React.ReactNode;
onClick?: () => void;
appearance?: string;
}) {
return <button onClick={onClick} data-appearance={appearance}>{children}</button>
},
VSCodeTextField: function MockVSCodeTextField({
value,
onInput,
placeholder
}: {
value?: string;
onInput?: (e: { target: { value: string } }) => void;
placeholder?: string;
}) {
return (
<input
type="text"
value={value}
onChange={(e) => onInput?.({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
)
},
VSCodeLink: function MockVSCodeLink({
children,
href
}: {
children: React.ReactNode;
href?: string;
}) {
return <a href={href}>{children}</a>
}
}))
describe('ChatView', () => {
const mockShowHistoryView = jest.fn()
const mockHideAnnouncement = jest.fn()
// Mock window.postMessage to trigger state hydration
const mockPostMessage = (state: Partial<ExtensionState>) => {
window.postMessage({
type: 'state',
state: {
version: '1.0.0',
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
alwaysAllowExecute: false,
...state
}
}, '*')
}
let mockState: ExtensionStateContextType
describe('ChatView - Auto Approval Tests', () => {
beforeEach(() => {
jest.clearAllMocks()
})
beforeEach(() => {
it('auto-approves browser actions when alwaysAllowBrowser is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the browser action ask message
mockPostMessage({
alwaysAllowBrowser: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
text: JSON.stringify({ action: 'launch', url: 'http://example.com' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves read-only tools when alwaysAllowReadOnly is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowReadOnly: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the read-only tool ask message
mockPostMessage({
alwaysAllowReadOnly: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'readFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves write tools when alwaysAllowWrite is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowWrite: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the write tool ask message
mockPostMessage({
alwaysAllowWrite: true,
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'tool',
ts: Date.now(),
text: JSON.stringify({ tool: 'editedExistingFile', path: 'test.txt' }),
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('auto-approves allowed commands when alwaysAllowExecute is enabled', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: 'npm test',
partial: false
}
]
})
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
it('does not auto-approve disallowed commands even when alwaysAllowExecute is enabled', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the disallowed command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: 'rm -rf /',
partial: false
}
]
})
// Verify no auto-approval message was sent
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
describe('Command Chaining Tests', () => {
it('auto-approves chained commands when all parts are allowed', async () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
// Test various allowed command chaining scenarios
const allowedChainedCommands = [
'npm test && npm run build',
'npm test; npm run build',
'npm test || npm run build',
'npm test | npm run build'
]
for (const command of allowedChainedCommands) {
jest.clearAllMocks()
mockState = {
clineMessages: [],
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
// Then send the chained command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test', 'npm run build'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
version: '1.0.0',
customInstructions: '',
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
alwaysAllowBrowser: true,
openRouterModels: {},
didHydrateState: true,
showWelcome: false,
theme: 'dark',
filePaths: [],
taskHistory: [],
shouldShowAnnouncement: false,
uriScheme: 'vscode',
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: command,
partial: false
}
]
})
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn(),
setCustomInstructions: jest.fn(),
setAlwaysAllowReadOnly: jest.fn(),
setAlwaysAllowWrite: jest.fn(),
setAlwaysAllowExecute: jest.fn(),
setAlwaysAllowBrowser: jest.fn()
}
// Mock the useExtensionState hook
jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
// Wait for the auto-approval message
await waitFor(() => {
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
}
})
const renderChatView = () => {
return render(
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={mockHideAnnouncement}
showHistoryView={mockShowHistoryView}
/>
)
}
it('does not auto-approve chained commands when any part is disallowed', () => {
render(
<ExtensionStateContextProvider>
<ChatView
isHidden={false}
showAnnouncement={false}
hideAnnouncement={() => {}}
showHistoryView={() => {}}
/>
</ExtensionStateContextProvider>
)
describe('Always Allow Logic', () => {
it('should auto-approve read-only tool actions when alwaysAllowReadOnly is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'readFile' }),
ts: Date.now(),
}
]
renderChatView()
// Test various command chaining scenarios with disallowed parts
const disallowedChainedCommands = [
'npm test && rm -rf /',
'npm test; rm -rf /',
'npm test || rm -rf /',
'npm test | rm -rf /',
'npm test $(echo dangerous)',
'npm test `echo dangerous`'
]
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
disallowedChainedCommands.forEach(command => {
// First hydrate state with initial task
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
}
]
})
it('should auto-approve all file listing tool types when alwaysAllowReadOnly is true', () => {
const fileListingTools = [
'readFile', 'listFiles', 'listFilesTopLevel',
'listFilesRecursive', 'listCodeDefinitionNames', 'searchFiles'
]
fileListingTools.forEach(tool => {
jest.clearAllMocks()
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
// Then send the chained command ask message
mockPostMessage({
alwaysAllowExecute: true,
allowedCommands: ['npm test'],
clineMessages: [
{
type: 'say',
say: 'task',
ts: Date.now() - 2000,
text: 'Initial task'
},
{
type: 'ask',
ask: 'command',
ts: Date.now(),
text: command,
partial: false
}
]
})
it('should auto-approve write tool actions when alwaysAllowWrite is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'editedExistingFile' }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should auto-approve allowed execute commands when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'npm install',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should not auto-approve disallowed execute commands even when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'rm -rf /',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it('should not auto-approve commands with chaining characters when alwaysAllowExecute is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'command',
text: 'npm install && rm -rf /',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
it('should auto-approve browser actions when alwaysAllowBrowser is true', () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'browser_action_launch',
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
it('should not auto-approve when corresponding alwaysAllow flag is false', () => {
mockState.alwaysAllowReadOnly = false
mockState.clineMessages = [
{
type: 'ask',
ask: 'tool',
text: JSON.stringify({ tool: 'readFile' }),
ts: Date.now(),
}
]
renderChatView()
expect(vscode.postMessage).not.toHaveBeenCalled()
})
})
describe('Streaming State', () => {
it('should show cancel button while streaming and trigger cancel on click', async () => {
mockState.clineMessages = [
{
type: 'say',
say: 'task',
ts: Date.now(),
},
{
type: 'say',
say: 'text',
partial: true,
ts: Date.now(),
}
]
renderChatView()
const cancelButton = screen.getByText('Cancel')
await userEvent.click(cancelButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'cancelTask'
})
})
it('should show terminate button when task is paused and trigger terminate on click', async () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'resume_task',
ts: Date.now(),
}
]
renderChatView()
const terminateButton = screen.getByText('Terminate')
await userEvent.click(terminateButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'clearTask'
})
})
it('should show retry button when API error occurs and trigger retry on click', async () => {
mockState.clineMessages = [
{
type: 'ask',
ask: 'api_req_failed',
ts: Date.now(),
}
]
renderChatView()
const retryButton = screen.getByText('Retry')
await userEvent.click(retryButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
// Verify no auto-approval message was sent for chained commands with disallowed parts
expect(vscode.postMessage).not.toHaveBeenCalledWith({
type: 'askResponse',
askResponse: 'yesButtonClicked'
})
})
})
})
})

View File

@@ -1,4 +1,4 @@
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea } from "@vscode/webview-ui-toolkit/react"
import { VSCodeButton, VSCodeCheckbox, VSCodeLink, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { memo, useEffect, useState } from "react"
import { useExtensionState } from "../../context/ExtensionStateContext"
import { validateApiConfiguration, validateModelId } from "../../utils/validate"
@@ -26,9 +26,13 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
alwaysAllowBrowser,
setAlwaysAllowBrowser,
openRouterModels,
setAllowedCommands,
allowedCommands,
} = useExtensionState()
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [modelIdErrorMessage, setModelIdErrorMessage] = useState<string | undefined>(undefined)
const [commandInput, setCommandInput] = useState("")
const handleSubmit = () => {
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
@@ -42,6 +46,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
onDone()
}
}
@@ -51,22 +56,31 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
setModelIdErrorMessage(undefined)
}, [apiConfiguration])
// validate as soon as the component is mounted
/*
useEffect will use stale values of variables if they are not included in the dependency array. so trying to use useEffect with a dependency array of only one value for example will use any other variables' old values. In most cases you don't want this, and should opt to use react-use hooks.
// Initial validation on mount
useEffect(() => {
// uses someVar and anotherVar
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [someVar])
If we only want to run code once on mount we can use react-use's useEffectOnce or useMount
*/
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const modelIdValidationResult = validateModelId(apiConfiguration, openRouterModels)
setApiErrorMessage(apiValidationResult)
setModelIdErrorMessage(modelIdValidationResult)
}, [apiConfiguration, openRouterModels])
const handleResetState = () => {
vscode.postMessage({ type: "resetState" })
}
const handleAddCommand = () => {
const currentCommands = allowedCommands ?? []
if (commandInput && !currentCommands.includes(commandInput)) {
const newCommands = [...currentCommands, commandInput]
setAllowedCommands(newCommands)
setCommandInput("")
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
})
}
}
return (
<div
style={{
@@ -149,27 +163,12 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
padding: "8px",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
When enabled, Cline will automatically write to files and create directories
without requiring you to click the Approve button.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowExecute}
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve execute operations</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
When enabled, Cline will automatically CLI commands without requiring
you to click the Approve button.
WARNING: When enabled, Cline will automatically create and edit files without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
</p>
</div>
@@ -183,13 +182,90 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
padding: "8px",
backgroundColor: "var(--vscode-errorBackground)",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
When enabled, Cline will automatically perform browser actions without requiring
you to click the Approve button.
WARNING: When enabled, Cline will automatically perform browser actions without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.<br/><br/>NOTE: The checkbox only applies when the model supports computer use.
</p>
</div>
<div style={{ marginBottom: 5 }}>
<VSCodeCheckbox
checked={alwaysAllowExecute}
onChange={(e: any) => setAlwaysAllowExecute(e.target.checked)}>
<span style={{ fontWeight: "500" }}>Always approve allowed execute operations</span>
</VSCodeCheckbox>
<p
style={{
fontSize: "12px",
marginTop: "5px",
padding: "8px",
backgroundColor: "var(--vscode-errorBackground)",
border: "1px solid var(--vscode-errorBorder)",
borderRadius: "4px",
color: "var(--vscode-errorForeground)",
}}>
WARNING: When enabled, Cline will automatically execute allowed terminal commands without requiring approval. This is potentially very dangerous and could lead to unwanted system modifications or security risks. Enable only if you fully trust the AI and understand the risks.
</p>
</div>
{alwaysAllowExecute && (
<div style={{ marginBottom: 5 }}>
<div style={{ marginBottom: "10px" }}>
<span style={{ fontWeight: "500" }}>Allowed Auto-Execute Commands</span>
<p style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
Command prefixes that can be auto-executed when "Always approve execute operations" is enabled.
</p>
<div style={{ display: 'flex', gap: '5px', marginTop: '10px' }}>
<VSCodeTextField
value={commandInput}
onInput={(e: any) => setCommandInput(e.target.value)}
placeholder="Enter command prefix (e.g., 'git ')"
style={{ flexGrow: 1 }}
/>
<VSCodeButton onClick={handleAddCommand}>
Add
</VSCodeButton>
</div>
<div style={{ marginTop: '10px' }}>
{(allowedCommands ?? []).map((cmd, index) => (
<div key={index} style={{
display: 'flex',
alignItems: 'center',
gap: '5px',
marginBottom: '5px'
}}>
<span>{cmd}</span>
<VSCodeButton
appearance="icon"
onClick={() => {
const newCommands = (allowedCommands ?? []).filter((_, i) => i !== index)
setAllowedCommands(newCommands)
vscode.postMessage({
type: "allowedCommands",
commands: newCommands
})
}}
>
<span className="codicon codicon-close" />
</VSCodeButton>
</div>
))}
</div>
</div>
</div>
)}
{IS_DEV && (
<>
<div style={{ marginTop: "10px", marginBottom: "4px" }}>Debug</div>
@@ -218,8 +294,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {
}}>
<p style={{ wordWrap: "break-word", margin: 0, padding: 0 }}>
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/cline/cline" style={{ display: "inline" }}>
https://github.com/cline/cline
<VSCodeLink href="https://github.com/RooVetGit/Roo-Cline" style={{ display: "inline" }}>
https://github.com/RooVetGit/Roo-Cline
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic", margin: "10px 0 0 0", padding: 0 }}>v{version}</p>

View File

@@ -1,230 +1,221 @@
import { render, screen, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ExtensionStateContextType } from '../../../context/ExtensionStateContext'
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import SettingsView from '../SettingsView'
import { ExtensionStateContextProvider } from '../../../context/ExtensionStateContext'
import { vscode } from '../../../utils/vscode'
import * as ExtensionStateContext from '../../../context/ExtensionStateContext'
import { ModelInfo } from '../../../../../src/shared/api'
// Mock dependencies
// Mock vscode API
jest.mock('../../../utils/vscode', () => ({
vscode: {
postMessage: jest.fn()
}
vscode: {
postMessage: jest.fn(),
},
}))
// Mock validation functions
jest.mock('../../../utils/validate', () => ({
validateApiConfiguration: jest.fn(() => undefined),
validateModelId: jest.fn(() => undefined)
}))
// Mock ApiOptions component
jest.mock('../ApiOptions', () => ({
__esModule: true,
default: () => <div data-testid="mock-api-options" />
}))
// Mock VS Code components
// Mock VSCode components
jest.mock('@vscode/webview-ui-toolkit/react', () => ({
VSCodeButton: ({ children, onClick }: any) => (
<button onClick={onClick}>{children}</button>
),
VSCodeCheckbox: ({ children, checked, onChange }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={e => onChange(e)}
aria-checked={checked}
/>
{children}
</label>
),
VSCodeTextArea: ({ children, value, onInput }: any) => (
<textarea
data-testid="custom-instructions"
value={value}
readOnly
aria-label="Custom Instructions"
>{children}</textarea>
),
VSCodeLink: ({ children, href }: any) => (
<a href={href}>{children}</a>
)
VSCodeButton: ({ children, onClick, appearance }: any) => (
appearance === 'icon' ?
<button onClick={onClick} className="codicon codicon-close" aria-label="Remove command">
<span className="codicon codicon-close" />
</button> :
<button onClick={onClick} data-appearance={appearance}>{children}</button>
),
VSCodeCheckbox: ({ children, onChange, checked }: any) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange({ target: { checked: e.target.checked } })}
aria-label={typeof children === 'string' ? children : undefined}
/>
{children}
</label>
),
VSCodeTextField: ({ value, onInput, placeholder }: any) => (
<input
type="text"
value={value}
onChange={(e) => onInput({ target: { value: e.target.value } })}
placeholder={placeholder}
/>
),
VSCodeTextArea: () => <textarea />,
VSCodeLink: () => <a />,
VSCodeDropdown: ({ children, value, onChange }: any) => (
<select value={value} onChange={onChange}>
{children}
</select>
),
VSCodeOption: ({ children, value }: any) => (
<option value={value}>{children}</option>
),
VSCodeRadio: ({ children, value, checked, onChange }: any) => (
<input
type="radio"
value={value}
checked={checked}
onChange={onChange}
/>
),
VSCodeRadioGroup: ({ children, value, onChange }: any) => (
<div onChange={onChange}>
{children}
</div>
)
}))
describe('SettingsView', () => {
const mockOnDone = jest.fn()
const mockSetAlwaysAllowWrite = jest.fn()
const mockSetAlwaysAllowReadOnly = jest.fn()
const mockSetCustomInstructions = jest.fn()
const mockSetAlwaysAllowExecute = jest.fn()
let mockState: ExtensionStateContextType
const mockOpenRouterModels: Record<string, ModelInfo> = {
'claude-3-sonnet': {
maxTokens: 200000,
contextWindow: 200000,
supportsImages: true,
supportsComputerUse: true,
supportsPromptCache: true,
inputPrice: 0.000008,
outputPrice: 0.000024,
description: "Anthropic's Claude 3 Sonnet model"
}
// 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,
...state
}
}, '*')
}
beforeEach(() => {
jest.clearAllMocks()
const renderSettingsView = () => {
const onDone = jest.fn()
render(
<ExtensionStateContextProvider>
<SettingsView onDone={onDone} />
</ExtensionStateContextProvider>
)
// Hydrate initial state
mockPostMessage({})
return { onDone }
}
mockState = {
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
},
version: '1.0.0',
customInstructions: 'Test instructions',
alwaysAllowReadOnly: true,
alwaysAllowWrite: true,
alwaysAllowExecute: true,
openRouterModels: mockOpenRouterModels,
didHydrateState: true,
showWelcome: false,
theme: 'dark',
filePaths: [],
taskHistory: [],
shouldShowAnnouncement: false,
clineMessages: [],
uriScheme: 'vscode',
describe('SettingsView - Allowed Commands', () => {
beforeEach(() => {
jest.clearAllMocks()
})
setAlwaysAllowReadOnly: mockSetAlwaysAllowReadOnly,
setAlwaysAllowWrite: mockSetAlwaysAllowWrite,
setCustomInstructions: mockSetCustomInstructions,
setAlwaysAllowExecute: mockSetAlwaysAllowExecute,
setApiConfiguration: jest.fn(),
setShowAnnouncement: jest.fn()
}
it('shows allowed commands section when alwaysAllowExecute is enabled', () => {
renderSettingsView()
// Mock the useExtensionState hook
jest.spyOn(ExtensionStateContext, 'useExtensionState').mockReturnValue(mockState)
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
const renderSettingsView = () => {
return render(
<SettingsView onDone={mockOnDone} />
)
}
// Verify allowed commands section appears
expect(screen.getByText(/Allowed Auto-Execute Commands/i)).toBeInTheDocument()
expect(screen.getByPlaceholderText(/Enter command prefix/i)).toBeInTheDocument()
})
describe('Checkboxes', () => {
it('should toggle alwaysAllowWrite checkbox', async () => {
mockState.alwaysAllowWrite = false
renderSettingsView()
it('adds new command to the list', () => {
renderSettingsView()
const writeCheckbox = screen.getByRole('checkbox', {
name: /Always approve write operations/i
})
expect(writeCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(writeCheckbox)
})
expect(mockSetAlwaysAllowWrite).toHaveBeenCalledWith(true)
})
it('should toggle alwaysAllowExecute checkbox', async () => {
mockState.alwaysAllowExecute = false
renderSettingsView()
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve execute operations/i
})
expect(executeCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(executeCheckbox)
})
expect(mockSetAlwaysAllowExecute).toHaveBeenCalledWith(true)
})
it('should toggle alwaysAllowReadOnly checkbox', async () => {
mockState.alwaysAllowReadOnly = false
renderSettingsView()
const readOnlyCheckbox = screen.getByRole('checkbox', {
name: /Always approve read-only operations/i
})
expect(readOnlyCheckbox).not.toBeChecked()
await act(async () => {
await userEvent.click(readOnlyCheckbox)
})
expect(mockSetAlwaysAllowReadOnly).toHaveBeenCalledWith(true)
})
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
describe('Form Submission', () => {
it('should send correct messages when form is submitted', async () => {
renderSettingsView()
// Add a new command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
// Submit form
const doneButton = screen.getByRole('button', { name: /Done/i })
await act(async () => {
await userEvent.click(doneButton)
})
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Verify messages were sent in the correct order
const calls = (vscode.postMessage as jest.Mock).mock.calls
expect(calls).toHaveLength(5)
// Verify command was added
expect(screen.getByText('npm test')).toBeInTheDocument()
expect(calls[0][0]).toEqual({
type: 'apiConfiguration',
apiConfiguration: {
apiProvider: 'anthropic',
apiModelId: 'claude-3-sonnet'
}
})
expect(calls[1][0]).toEqual({
type: 'customInstructions',
text: 'Test instructions'
})
expect(calls[2][0]).toEqual({
type: 'alwaysAllowReadOnly',
bool: true
})
expect(calls[3][0]).toEqual({
type: 'alwaysAllowWrite',
bool: true
})
expect(calls[4][0]).toEqual({
type: 'alwaysAllowExecute',
bool: true
})
// Verify onDone was called
expect(mockOnDone).toHaveBeenCalled()
})
// Verify VSCode message was sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: 'allowedCommands',
commands: ['npm test']
})
})
describe('Accessibility', () => {
it('should have accessible form controls', () => {
renderSettingsView()
it('removes command from the list', () => {
renderSettingsView()
// Check for proper labels and ARIA attributes
const writeCheckbox = screen.getByRole('checkbox', {
name: /Always approve write operations/i
})
expect(writeCheckbox).toHaveAttribute('aria-checked')
const textarea = screen.getByRole('textbox', {
name: /Custom Instructions/i
})
expect(textarea).toBeInTheDocument()
})
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Remove the command
const removeButton = screen.getByRole('button', { name: 'Remove command' })
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',
commands: []
})
})
it('prevents duplicate commands', () => {
renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command twice
const input = screen.getByPlaceholderText(/Enter command prefix/i)
const addButton = screen.getByText('Add')
// First addition
fireEvent.change(input, { target: { value: 'npm test' } })
fireEvent.click(addButton)
// Second addition attempt
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)
})
it('saves allowed commands when clicking Done', () => {
const { onDone } = renderSettingsView()
// Enable always allow execute
const executeCheckbox = screen.getByRole('checkbox', {
name: /Always approve allowed execute operations/i
})
fireEvent.click(executeCheckbox)
// Add a command
const input = screen.getByPlaceholderText(/Enter command prefix/i)
fireEvent.change(input, { target: { value: 'npm test' } })
const addButton = screen.getByText('Add')
fireEvent.click(addButton)
// Click Done
const doneButton = screen.getByText('Done')
fireEvent.click(doneButton)
// Verify VSCode messages were sent
expect(vscode.postMessage).toHaveBeenCalledWith(expect.objectContaining({
type: 'allowedCommands',
commands: ['npm test']
}))
expect(onDone).toHaveBeenCalled()
})
})

View File

@@ -24,6 +24,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowExecute: (value: boolean) => void
setAlwaysAllowBrowser: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
setAllowedCommands: (value: string[]) => void
}
const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@@ -34,6 +35,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
clineMessages: [],
taskHistory: [],
shouldShowAnnouncement: false,
allowedCommands: [],
})
const [didHydrateState, setDidHydrateState] = useState(false)
const [showWelcome, setShowWelcome] = useState(false)
@@ -121,6 +123,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAlwaysAllowExecute: (value) => setState((prevState) => ({ ...prevState, alwaysAllowExecute: value })),
setAlwaysAllowBrowser: (value) => setState((prevState) => ({ ...prevState, alwaysAllowBrowser: value })),
setShowAnnouncement: (value) => setState((prevState) => ({ ...prevState, shouldShowAnnouncement: value })),
setAllowedCommands: (value) => setState((prevState) => ({ ...prevState, allowedCommands: value })),
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View File

@@ -0,0 +1,60 @@
import React from 'react'
import { render, screen, act } from '@testing-library/react'
import { ExtensionStateContextProvider, useExtensionState } from '../ExtensionStateContext'
// Test component that consumes the context
const TestComponent = () => {
const { allowedCommands, setAllowedCommands } = useExtensionState()
return (
<div>
<div data-testid="allowed-commands">{JSON.stringify(allowedCommands)}</div>
<button
data-testid="update-button"
onClick={() => setAllowedCommands(['npm install', 'git status'])}
>
Update Commands
</button>
</div>
)
}
describe('ExtensionStateContext', () => {
it('initializes with empty allowedCommands array', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([])
})
it('updates allowedCommands through setAllowedCommands', () => {
render(
<ExtensionStateContextProvider>
<TestComponent />
</ExtensionStateContextProvider>
)
act(() => {
screen.getByTestId('update-button').click()
})
expect(JSON.parse(screen.getByTestId('allowed-commands').textContent!)).toEqual([
'npm install',
'git status'
])
})
it('throws error when used outside provider', () => {
// Suppress console.error for this test since we expect an error
const consoleSpy = jest.spyOn(console, 'error')
consoleSpy.mockImplementation(() => {})
expect(() => {
render(<TestComponent />)
}).toThrow('useExtensionState must be used within an ExtensionStateContextProvider')
consoleSpy.mockRestore()
})
})