Roo-Code/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts
Jack Pippett 4016f41c65 fix: shell meta-interpretation and API key exposure
- Change ExecaTerminalProcess shell option from true to false so execa
  parses the command without invoking a shell, preventing shell
  metacharacter interpretation.
- Redact secret API keys in getStateToPostToWebview() before serializing
  state to the webview renderer.
- Preserve existing secret values in ProviderSettingsManager.saveConfig()
  when the webview round-trips the '__ROO_REDACTED__' sentinel.
2026-04-28 12:36:37 -07:00

190 lines
6.2 KiB
TypeScript

// npx vitest run integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts
const mockPid = 12345
vitest.mock("execa", () => {
const mockKill = vitest.fn()
const execa = vitest.fn((options: any) => {
return (_template: TemplateStringsArray, ...args: any[]) => ({
pid: mockPid,
iterable: (_opts: any) =>
(async function* () {
yield "test output\n"
})(),
kill: mockKill,
})
})
return { execa, ExecaError: class extends Error {} }
})
vitest.mock("ps-tree", () => ({
default: vitest.fn((_: number, cb: any) => cb(null, [])),
}))
import { execa } from "execa"
import { ExecaTerminalProcess } from "../ExecaTerminalProcess"
import { BaseTerminal } from "../BaseTerminal"
import type { RooTerminal } from "../types"
describe("ExecaTerminalProcess", () => {
let mockTerminal: RooTerminal
let terminalProcess: ExecaTerminalProcess
let originalEnv: NodeJS.ProcessEnv
beforeEach(() => {
originalEnv = { ...process.env }
BaseTerminal.setExecaShellPath(undefined)
mockTerminal = {
provider: "execa",
id: 1,
busy: false,
running: false,
getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/cwd"),
isClosed: vitest.fn().mockReturnValue(false),
runCommand: vitest.fn(),
setActiveStream: vitest.fn(),
shellExecutionComplete: vitest.fn(),
getProcessesWithOutput: vitest.fn().mockReturnValue([]),
getUnretrievedOutput: vitest.fn().mockReturnValue(""),
getLastCommand: vitest.fn().mockReturnValue(""),
cleanCompletedProcessQueue: vitest.fn(),
} as unknown as RooTerminal
terminalProcess = new ExecaTerminalProcess(mockTerminal)
})
afterEach(() => {
process.env = originalEnv
vitest.clearAllMocks()
})
describe("UTF-8 encoding fix", () => {
it("should set LANG and LC_ALL to en_US.UTF-8", async () => {
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
expect(execaMock).toHaveBeenCalledWith(
expect.objectContaining({
shell: false,
cwd: "/test/cwd",
all: true,
env: expect.objectContaining({
LANG: "en_US.UTF-8",
LC_ALL: "en_US.UTF-8",
}),
}),
)
})
it("should preserve existing environment variables", async () => {
process.env.EXISTING_VAR = "existing"
terminalProcess = new ExecaTerminalProcess(mockTerminal)
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
const calledOptions = execaMock.mock.calls[0][0] as any
expect(calledOptions.env.EXISTING_VAR).toBe("existing")
})
it("should override existing LANG and LC_ALL values", async () => {
process.env.LANG = "C"
process.env.LC_ALL = "POSIX"
terminalProcess = new ExecaTerminalProcess(mockTerminal)
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
const calledOptions = execaMock.mock.calls[0][0] as any
expect(calledOptions.env.LANG).toBe("en_US.UTF-8")
expect(calledOptions.env.LC_ALL).toBe("en_US.UTF-8")
})
it("should use execaShellPath when set", async () => {
BaseTerminal.setExecaShellPath("/bin/bash")
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
expect(execaMock).toHaveBeenCalledWith(
expect.objectContaining({
shell: "/bin/bash",
}),
)
})
it("should fall back to shell=false when execaShellPath is undefined", async () => {
BaseTerminal.setExecaShellPath(undefined)
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
expect(execaMock).toHaveBeenCalledWith(
expect.objectContaining({
shell: false,
}),
)
})
})
describe("basic functionality", () => {
it("should create instance with terminal reference", () => {
expect(terminalProcess).toBeInstanceOf(ExecaTerminalProcess)
expect(terminalProcess.terminal).toBe(mockTerminal)
})
it("should emit shell_execution_complete with exitCode 0", async () => {
const spy = vitest.fn()
terminalProcess.on("shell_execution_complete", spy)
await terminalProcess.run("echo test")
expect(spy).toHaveBeenCalledWith({ exitCode: 0 })
})
it("should emit completed event with full output", async () => {
const spy = vitest.fn()
terminalProcess.on("completed", spy)
await terminalProcess.run("echo test")
expect(spy).toHaveBeenCalledWith("test output\n")
})
it("should set and clear active stream", async () => {
await terminalProcess.run("echo test")
expect(mockTerminal.setActiveStream).toHaveBeenCalledWith(expect.any(Object), mockPid)
expect(mockTerminal.setActiveStream).toHaveBeenLastCalledWith(undefined)
})
})
describe("trimRetrievedOutput", () => {
it("clears buffer when all output has been retrieved", () => {
// Set up a scenario where all output has been retrieved
terminalProcess["fullOutput"] = "test output data"
terminalProcess["lastRetrievedIndex"] = 16 // Same as fullOutput.length
// Access the protected method through type casting
;(terminalProcess as any).trimRetrievedOutput()
expect(terminalProcess["fullOutput"]).toBe("")
expect(terminalProcess["lastRetrievedIndex"]).toBe(0)
})
it("does not clear buffer when there is unretrieved output", () => {
// Set up a scenario where not all output has been retrieved
terminalProcess["fullOutput"] = "test output data"
terminalProcess["lastRetrievedIndex"] = 5 // Less than fullOutput.length
;(terminalProcess as any).trimRetrievedOutput()
// Buffer should NOT be cleared - there's still unretrieved content
expect(terminalProcess["fullOutput"]).toBe("test output data")
expect(terminalProcess["lastRetrievedIndex"]).toBe(5)
})
it("does nothing when buffer is already empty", () => {
terminalProcess["fullOutput"] = ""
terminalProcess["lastRetrievedIndex"] = 0
;(terminalProcess as any).trimRetrievedOutput()
expect(terminalProcess["fullOutput"]).toBe("")
expect(terminalProcess["lastRetrievedIndex"]).toBe(0)
})
it("clears buffer when lastRetrievedIndex exceeds fullOutput length", () => {
// Edge case: index is greater than current length (could happen if output was modified)
terminalProcess["fullOutput"] = "short"
terminalProcess["lastRetrievedIndex"] = 100
;(terminalProcess as any).trimRetrievedOutput()
expect(terminalProcess["fullOutput"]).toBe("")
expect(terminalProcess["lastRetrievedIndex"]).toBe(0)
})
})
})