Skip to content

Commit 44e7807

Browse files
committed
test(e2e): smoke-test VS Code terminal profile override lifecycle
1 parent 0a65ce2 commit 44e7807

6 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "TERMINAL_PROFILE_E2E_OVERRIDE"
6+
},
7+
"response": {
8+
"toolCalls": [
9+
{
10+
"name": "execute_command",
11+
"arguments": "{\"command\":\"printf 'zoo-profile-override-ok\\\\n' > terminal-profile-e2e/terminal-profile-override.txt\"}",
12+
"id": "call_terminal_profile_override_001"
13+
}
14+
]
15+
}
16+
},
17+
{
18+
"match": {
19+
"userMessage": "TERMINAL_PROFILE_E2E_DEFAULT"
20+
},
21+
"response": {
22+
"toolCalls": [
23+
{
24+
"name": "execute_command",
25+
"arguments": "{\"command\":\"printf 'zoo-profile-default-ok\\\\n' > terminal-profile-e2e/terminal-profile-default.txt\"}",
26+
"id": "call_terminal_profile_default_001"
27+
}
28+
]
29+
}
30+
}
31+
]
32+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { LLMock } from "@copilotkit/aimock"
2+
3+
import { toolResultContains } from "./tool-result"
4+
5+
type TerminalProfileToolCall = {
6+
name: "execute_command" | "attempt_completion"
7+
params: Record<string, unknown>
8+
id: string
9+
}
10+
11+
type TerminalProfileFixture = {
12+
toolCallId: string
13+
expected: string[]
14+
toolCalls: TerminalProfileToolCall[]
15+
}
16+
17+
export function addTerminalProfileResultFixtures(mock: InstanceType<typeof LLMock>) {
18+
const fixtures: TerminalProfileFixture[] = [
19+
{
20+
toolCallId: "call_terminal_profile_override_001",
21+
expected: ["Exit code: 0"],
22+
toolCalls: [
23+
{
24+
name: "attempt_completion",
25+
params: { result: "Ran the command using the Zoo E2E Bash profile override." },
26+
id: "call_terminal_profile_override_002",
27+
},
28+
],
29+
},
30+
{
31+
toolCallId: "call_terminal_profile_default_001",
32+
expected: ["Exit code: 0"],
33+
toolCalls: [
34+
{
35+
name: "attempt_completion",
36+
params: { result: "Ran the command using the default terminal profile." },
37+
id: "call_terminal_profile_default_002",
38+
},
39+
],
40+
},
41+
]
42+
43+
for (const fixture of fixtures) {
44+
mock.addFixture({
45+
match: {
46+
toolCallId: fixture.toolCallId,
47+
predicate: (req) => toolResultContains(req, fixture.toolCallId, fixture.expected),
48+
},
49+
response: {
50+
toolCalls: fixture.toolCalls.map((toolCall) => ({
51+
name: toolCall.name,
52+
arguments: JSON.stringify(toolCall.params),
53+
id: toolCall.id,
54+
})),
55+
},
56+
})
57+
}
58+
}

apps/vscode-e2e/src/runTest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LLMock } from "@copilotkit/aimock"
77

88
import { addApplyDiffResultFixtures } from "./fixtures/apply-diff"
99
import { addExecuteCommandResultFixtures } from "./fixtures/execute-command"
10+
import { addTerminalProfileResultFixtures } from "./fixtures/terminal-profile"
1011
import { addListFilesResultFixtures } from "./fixtures/list-files"
1112
import { addReadFileResultFixtures } from "./fixtures/read-file"
1213
import { addSearchFilesResultFixtures } from "./fixtures/search-files"
@@ -108,6 +109,7 @@ async function main() {
108109
if (!isRecord) {
109110
addApplyDiffResultFixtures(mock)
110111
addExecuteCommandResultFixtures(mock)
112+
addTerminalProfileResultFixtures(mock)
111113
addListFilesResultFixtures(mock)
112114
addReadFileResultFixtures(mock)
113115
addSearchFilesResultFixtures(mock)
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* Linux-only e2e smoke test for the VS Code terminal profile override.
3+
*
4+
* Proves that:
5+
* 1. Setting a profile override causes commands to run through the selected
6+
* VS Code integrated-terminal shell without a shell_integration_warning.
7+
* 2. Clearing the override starts a fresh terminal on the next command.
8+
*
9+
* Windows profile coverage (cmd.exe fast-path, PowerShell) is proven by unit
10+
* tests in src/integrations/terminal/__tests__/. This test requires /bin/bash
11+
* which only exists on Linux/macOS.
12+
*/
13+
import * as assert from "assert"
14+
import * as fs from "fs/promises"
15+
import * as path from "path"
16+
import * as vscode from "vscode"
17+
18+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
19+
20+
import { sleep, waitUntilCompleted } from "../utils"
21+
import { setDefaultSuiteTimeout } from "../test-utils"
22+
23+
const TEST_DIR_NAME = "terminal-profile-e2e"
24+
const OVERRIDE_FILE = "terminal-profile-override.txt"
25+
const DEFAULT_FILE = "terminal-profile-default.txt"
26+
const PROFILE_NAME = "Zoo E2E Bash"
27+
28+
suite("Terminal Profile", function () {
29+
if (process.platform !== "linux") {
30+
return
31+
}
32+
33+
setDefaultSuiteTimeout(this)
34+
35+
let workspaceDir: string
36+
let testDir: string
37+
let originalProfiles: Record<string, unknown> | undefined
38+
39+
suiteSetup(async () => {
40+
const aimockUrl = process.env.AIMOCK_URL
41+
const isRecord = process.env.AIMOCK_RECORD === "true"
42+
43+
await globalThis.api.setConfiguration({
44+
apiProvider: "openrouter" as const,
45+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
46+
openRouterModelId: "anthropic/claude-sonnet-4.5",
47+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
48+
})
49+
50+
const workspaceFolders = vscode.workspace.workspaceFolders
51+
if (!workspaceFolders?.length) throw new Error("No workspace folder found")
52+
workspaceDir = workspaceFolders[0]!.uri.fsPath
53+
testDir = path.join(workspaceDir, TEST_DIR_NAME)
54+
await fs.rm(testDir, { recursive: true, force: true })
55+
await fs.mkdir(testDir, { recursive: true })
56+
57+
// Save the current global linux profiles so we can restore them in teardown.
58+
originalProfiles = vscode.workspace
59+
.getConfiguration("terminal.integrated.profiles")
60+
.inspect<Record<string, unknown>>("linux")?.globalValue
61+
62+
// Write the test profile to VS Code user (global) settings.
63+
// Terminal.getConfiguredProfiles() intentionally excludes workspace settings
64+
// for security, so global scope is required here.
65+
await vscode.workspace.getConfiguration("terminal.integrated.profiles").update(
66+
"linux",
67+
{
68+
...originalProfiles,
69+
[PROFILE_NAME]: { path: "/bin/bash", args: ["--noprofile", "--norc"] },
70+
},
71+
vscode.ConfigurationTarget.Global,
72+
)
73+
74+
// Activate the profile override in-process. api.setConfiguration() alone
75+
// does not call Terminal.setTerminalProfile(), so this dedicated method is
76+
// required to wire up the static in the running extension host.
77+
globalThis.api.setTerminalProfile(PROFILE_NAME)
78+
})
79+
80+
suiteTeardown(async () => {
81+
try {
82+
await globalThis.api.cancelCurrentTask()
83+
} catch {
84+
// task may not be running
85+
}
86+
87+
// Always restore — order matters: clear profile first so any subsequent
88+
// terminal creation uses the default, then restore VS Code settings.
89+
globalThis.api.setTerminalProfile(undefined)
90+
91+
await vscode.workspace
92+
.getConfiguration("terminal.integrated.profiles")
93+
.update("linux", originalProfiles, vscode.ConfigurationTarget.Global)
94+
95+
await fs.rm(testDir, { recursive: true, force: true })
96+
97+
const aimockUrl = process.env.AIMOCK_URL
98+
const isRecord = process.env.AIMOCK_RECORD === "true"
99+
await globalThis.api.setConfiguration({
100+
apiProvider: "openrouter" as const,
101+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
102+
openRouterModelId: "openai/gpt-4.1",
103+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
104+
})
105+
})
106+
107+
setup(async () => {
108+
try {
109+
await globalThis.api.cancelCurrentTask()
110+
} catch {
111+
// task may not be running
112+
}
113+
114+
await fs.rm(path.join(testDir, OVERRIDE_FILE), { force: true })
115+
await fs.rm(path.join(testDir, DEFAULT_FILE), { force: true })
116+
await sleep(100)
117+
})
118+
119+
teardown(async () => {
120+
try {
121+
await globalThis.api.cancelCurrentTask()
122+
} catch {
123+
// task may not be running
124+
}
125+
126+
await sleep(100)
127+
})
128+
129+
test("executes command through profile override without shell integration warning", async function () {
130+
const api = globalThis.api
131+
const messages: ClineMessage[] = []
132+
133+
const messageHandler = ({ message }: { message: ClineMessage }) => {
134+
messages.push(message)
135+
}
136+
api.on(RooCodeEventName.Message, messageHandler)
137+
138+
try {
139+
await waitUntilCompleted({
140+
api,
141+
start: () =>
142+
api.startNewTask({
143+
configuration: {
144+
mode: "code",
145+
autoApprovalEnabled: true,
146+
alwaysAllowExecute: true,
147+
allowedCommands: ["*"],
148+
terminalShellIntegrationDisabled: false,
149+
},
150+
text: "TERMINAL_PROFILE_E2E_OVERRIDE",
151+
}),
152+
timeout: 90_000,
153+
})
154+
155+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
156+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
157+
158+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with a valid profile")
159+
assert.strictEqual(
160+
gotError,
161+
false,
162+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
163+
)
164+
165+
const content = await fs.readFile(path.join(testDir, OVERRIDE_FILE), "utf-8")
166+
assert.ok(content.includes("zoo-profile-override-ok"), `Output file should contain marker, got: ${content}`)
167+
168+
assert.ok(vscode.window.terminals.length >= 1, "At least one VS Code terminal should exist")
169+
} finally {
170+
api.off(RooCodeEventName.Message, messageHandler)
171+
}
172+
})
173+
174+
test("starts a fresh terminal after clearing the profile override", async function () {
175+
const api = globalThis.api
176+
const messages: ClineMessage[] = []
177+
178+
const messageHandler = ({ message }: { message: ClineMessage }) => {
179+
messages.push(message)
180+
}
181+
api.on(RooCodeEventName.Message, messageHandler)
182+
183+
try {
184+
// Clear the override — this also calls TerminalRegistry.closeIdleTerminals()
185+
// so the terminal from test 1 is disposed before this task runs.
186+
api.setTerminalProfile(undefined)
187+
await sleep(200) // let VS Code process the disposal before the next task
188+
189+
await waitUntilCompleted({
190+
api,
191+
start: () =>
192+
api.startNewTask({
193+
configuration: {
194+
mode: "code",
195+
autoApprovalEnabled: true,
196+
alwaysAllowExecute: true,
197+
allowedCommands: ["*"],
198+
terminalShellIntegrationDisabled: false,
199+
},
200+
text: "TERMINAL_PROFILE_E2E_DEFAULT",
201+
}),
202+
timeout: 90_000,
203+
})
204+
205+
const gotWarning = messages.some((m) => m.type === "say" && m.say === "shell_integration_warning")
206+
const gotError = messages.some((m) => m.type === "say" && m.say === "error")
207+
208+
assert.strictEqual(gotWarning, false, "Shell integration warning should not fire with the default profile")
209+
assert.strictEqual(
210+
gotError,
211+
false,
212+
`Unexpected error: ${messages.find((m) => m.type === "say" && m.say === "error")?.text}`,
213+
)
214+
215+
const content = await fs.readFile(path.join(testDir, DEFAULT_FILE), "utf-8")
216+
assert.ok(content.includes("zoo-profile-default-ok"), `Output file should contain marker, got: ${content}`)
217+
} finally {
218+
api.off(RooCodeEventName.Message, messageHandler)
219+
}
220+
})
221+
})

packages/types/src/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ export interface RooCodeAPI extends EventEmitter<RooCodeAPIEvents> {
139139
* @throws Error if the profile does not exist
140140
*/
141141
setActiveProfile(name: string): Promise<string | undefined>
142+
/**
143+
* Activates a VS Code terminal profile override for Zoo Code commands.
144+
* Passing undefined restores the VS Code default profile behavior and
145+
* closes idle terminals so the next command starts fresh.
146+
*/
147+
setTerminalProfile(name: string | undefined): void
142148
}
143149

144150
export interface RooCodeIpcServer extends EventEmitter<IpcServerEvents> {

src/extension/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { IpcServer } from "@roo-code/ipc"
2424

2525
import { Package } from "../shared/package"
2626
import { ClineProvider } from "../core/webview/ClineProvider"
27+
import { Terminal } from "../integrations/terminal/Terminal"
28+
import { TerminalRegistry } from "../integrations/terminal/TerminalRegistry"
2729
import { openClineInNewTab } from "../activate/registerCommands"
2830
import { getCommands } from "../services/command/commands"
2931
import { getModels } from "../api/providers/fetchers/modelCache"
@@ -477,6 +479,11 @@ export class API extends EventEmitter<RooCodeEvents> implements RooCodeAPI {
477479
await this.sidebarProvider.postStateToWebview()
478480
}
479481

482+
public setTerminalProfile(name: string | undefined): void {
483+
Terminal.setTerminalProfile(name)
484+
TerminalRegistry.closeIdleTerminals()
485+
}
486+
480487
// Provider Profile Management
481488

482489
public getProfiles(): string[] {

0 commit comments

Comments
 (0)