Roo-Code/webview-ui/src/components/chat/__tests__/CommandExecution.spec.tsx
RussellZager 618aa6652b
Inline terminal rendering parity with the VSCode Terminal (#11361)
* fix: render ANSI escape codes in inline terminal output

Fixes #10699

## Problem
The inline terminal output displayed raw ANSI bracket codes ([1m, [32m, etc.)
instead of rendering colors and formatting. This was caused by:
1. Backend: strip-ansi removing the ESC byte but leaving bracket remnants
2. Frontend: CodeBlock/Shiki having no ANSI rendering capability

## Solution
1. Backend: Replace strip-ansi with targeted removal of only VSCode shell
   integration sequences (OSC 633/133), preserving standard ANSI SGR codes
2. Frontend: Add new TerminalOutput component using ansi-to-html library
   that converts ANSI sequences to styled HTML spans
3. Map ANSI colors to VSCode terminal theme CSS variables for consistent
   theming across light/dark themes

## Testing
- Verified XSS prevention (escapeXML: true)
- Verified theme compatibility
- Added unit tests for both backend and frontend changes
- Updated existing tests to expect ANSI codes in output

Bundle size impact: ~3KB gzipped (ansi-to-html library)

Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com>

* fix: add eslint-disable for intentional ANSI control regex

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Zman771 <605281+Zman771@users.noreply.github.com>
Co-authored-by: Russell Zager <rzager@google.com>
2026-02-20 00:19:56 -07:00

608 lines
19 KiB
TypeScript

// pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/CommandExecution.spec.tsx
import React from "react"
import { render, screen, fireEvent } from "@testing-library/react"
import { CommandExecution } from "../CommandExecution"
import { ExtensionStateContext } from "../../../context/ExtensionStateContext"
// Mock dependencies
vi.mock("react-use", () => ({
useEvent: vi.fn(),
}))
import { vscode } from "../../../utils/vscode"
vi.mock("../../../utils/vscode", () => ({
vscode: {
postMessage: vi.fn(),
},
}))
vi.mock("../../common/CodeBlock", () => ({
default: ({ source }: { source: string }) => <div data-testid="code-block">{source}</div>,
}))
// Mock TerminalOutput
vi.mock("../TerminalOutput", () => ({
TerminalOutput: ({ content }: { content: string }) => <div data-testid="terminal-output">{content}</div>,
}))
vi.mock("../CommandPatternSelector", () => ({
CommandPatternSelector: ({ patterns, onAllowPatternChange, onDenyPatternChange }: any) => (
<div data-testid="command-pattern-selector">
{patterns.map((pattern: any, index: number) => (
<span key={index}>{pattern.pattern}</span>
))}
<button onClick={() => onAllowPatternChange(patterns[0]?.pattern)}>Allow</button>
<button onClick={() => onDenyPatternChange(patterns[0]?.pattern)}>Deny</button>
</div>
),
}))
// Mock ExtensionStateContext
const mockExtensionState = {
terminalShellIntegrationDisabled: false,
allowedCommands: ["npm"],
deniedCommands: ["rm"],
setAllowedCommands: vi.fn(),
setDeniedCommands: vi.fn(),
}
const ExtensionStateWrapper = ({ children }: { children: React.ReactNode }) => (
<ExtensionStateContext.Provider value={mockExtensionState as any}>{children}</ExtensionStateContext.Provider>
)
describe("CommandExecution", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("should render command without output", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install" />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("code-block")).toHaveTextContent("npm install")
})
it("should render command with output", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install\nOutput:\nInstalling packages..." />
</ExtensionStateWrapper>,
)
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("npm install")
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Installing packages...")
})
it("should render with custom icon and title", () => {
const icon = <span data-testid="custom-icon">📦</span>
const title = <span data-testid="custom-title">Installing Dependencies</span>
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install" icon={icon} title={title} />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("custom-icon")).toBeInTheDocument()
expect(screen.getByTestId("custom-title")).toBeInTheDocument()
})
it("should show command pattern selector for commands", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install express" />
</ExtensionStateWrapper>,
)
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
// Check that the command is shown in the pattern selector
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toHaveTextContent("npm install express")
})
it("should handle allow command change", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="git push" />
</ExtensionStateWrapper>,
)
const allowButton = screen.getByText("Allow")
fireEvent.click(allowButton)
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm", "git push"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm", "git push"],
deniedCommands: ["rm"],
},
})
})
it("should handle deny command change", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="docker run" />
</ExtensionStateWrapper>,
)
const denyButton = screen.getByText("Deny")
fireEvent.click(denyButton)
expect(mockExtensionState.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(mockExtensionState.setDeniedCommands).toHaveBeenCalledWith(["rm", "docker run"])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm"],
deniedCommands: ["rm", "docker run"],
},
})
})
it("should toggle allowed command", () => {
// Update the mock state to have "npm test" in allowedCommands
const stateWithNpmTest = {
...mockExtensionState,
allowedCommands: ["npm test"],
deniedCommands: ["rm"],
}
render(
<ExtensionStateContext.Provider value={stateWithNpmTest as any}>
<CommandExecution executionId="test-1" text="npm test" />
</ExtensionStateContext.Provider>,
)
const allowButton = screen.getByText("Allow")
fireEvent.click(allowButton)
// "npm test" is already in allowedCommands, so it should be removed
expect(stateWithNpmTest.setAllowedCommands).toHaveBeenCalledWith([])
expect(stateWithNpmTest.setDeniedCommands).toHaveBeenCalledWith(["rm"])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: [],
deniedCommands: ["rm"],
},
})
})
it("should toggle denied command", () => {
// Update the mock state to have "rm -rf" in deniedCommands
const stateWithRmRf = {
...mockExtensionState,
allowedCommands: ["npm"],
deniedCommands: ["rm -rf"],
}
render(
<ExtensionStateContext.Provider value={stateWithRmRf as any}>
<CommandExecution executionId="test-1" text="rm -rf" />
</ExtensionStateContext.Provider>,
)
const denyButton = screen.getByText("Deny")
fireEvent.click(denyButton)
// "rm -rf" is already in deniedCommands, so it should be removed
expect(stateWithRmRf.setAllowedCommands).toHaveBeenCalledWith(["npm"])
expect(stateWithRmRf.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm"],
deniedCommands: [],
},
})
})
it("should parse command with Output: separator", () => {
const commandText = `npm install
Output:
Installing...`
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text={commandText} />
</ExtensionStateWrapper>,
)
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("npm install")
})
it("should parse command with output", () => {
const commandText = `npm install
Output:
Suggested patterns: npm, npm install, npm run`
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text={commandText} />
</ExtensionStateWrapper>,
)
// First check that the command was parsed correctly
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("npm install")
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Suggested patterns: npm, npm install, npm run")
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show the full command in the selector
expect(selector).toHaveTextContent("npm install")
})
it("should handle commands with pipes", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="ls -la | grep test" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands from the pipe
expect(selector.textContent).toMatch(/ls -la|grep test/)
})
it("should handle commands with && operator", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="npm install && npm test" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands from the && chain
expect(selector.textContent).toMatch(/npm install|npm test|npm/)
})
it("should not show pattern selector for empty commands", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-1" text="" />
</ExtensionStateWrapper>,
)
expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
})
it("should expand output when terminal shell integration is disabled", () => {
const disabledState = {
...mockExtensionState,
terminalShellIntegrationDisabled: true,
}
const commandText = `npm install
Output:
Output here`
render(
<ExtensionStateContext.Provider value={disabledState as any}>
<CommandExecution executionId="test-1" text={commandText} />
</ExtensionStateContext.Provider>,
)
// Output should be visible when shell integration is disabled
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks).toHaveLength(1) // Only command block
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toHaveTextContent("Output here")
})
it("should handle undefined allowedCommands and deniedCommands", () => {
const stateWithUndefined = {
...mockExtensionState,
allowedCommands: undefined,
deniedCommands: undefined,
}
render(
<ExtensionStateContext.Provider value={stateWithUndefined as any}>
<CommandExecution executionId="test-1" text="npm install" />
</ExtensionStateContext.Provider>,
)
// Should show pattern selector when patterns are available
expect(screen.getByTestId("command-pattern-selector")).toBeInTheDocument()
})
it("should handle command change when moving from denied to allowed", () => {
// Update the mock state to have "rm file.txt" in deniedCommands
const stateWithRmInDenied = {
...mockExtensionState,
allowedCommands: ["npm"],
deniedCommands: ["rm file.txt"],
}
render(
<ExtensionStateContext.Provider value={stateWithRmInDenied as any}>
<CommandExecution executionId="test-1" text="rm file.txt" />
</ExtensionStateContext.Provider>,
)
const allowButton = screen.getByText("Allow")
fireEvent.click(allowButton)
// "rm file.txt" should be removed from denied and added to allowed
expect(stateWithRmInDenied.setAllowedCommands).toHaveBeenCalledWith(["npm", "rm file.txt"])
expect(stateWithRmInDenied.setDeniedCommands).toHaveBeenCalledWith([])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "updateSettings",
updatedSettings: {
allowedCommands: ["npm", "rm file.txt"],
deniedCommands: [],
},
})
})
describe("integration with CommandPatternSelector", () => {
it("should show complex commands with multiple operators", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-6" text="npm install && npm test || echo 'failed'" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands from the complex chain
expect(selector.textContent).toMatch(/npm install|npm test|echo|npm/)
})
it("should handle commands with output", () => {
const commandWithOutput = `npm install
Output:
Installing packages...
Other output here`
render(
<ExtensionStateWrapper>
<CommandExecution
executionId="test-6"
text={commandWithOutput}
icon={<span>icon</span>}
title={<span>Run Command</span>}
/>
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show the command in the selector
expect(selector).toHaveTextContent("npm install")
})
it("should handle commands with subshells", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-7" text="echo $(whoami) && git status" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands
expect(selector.textContent).toMatch(/echo|whoami|git status|git/)
})
it("should handle commands with backtick subshells", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-8" text="git commit -m `date`" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands
expect(selector.textContent).toMatch(/git commit|date|git/)
})
it("should handle commands with special characters", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-9" text="cd ~/projects && npm start" />
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show one of the individual commands
expect(selector.textContent).toMatch(/cd ~\/projects|npm start|cd|npm/)
})
it("should handle commands with mixed content including output", () => {
const commandWithMixedContent = `npm test
Output:
Running tests...
✓ Test 1 passed
✓ Test 2 passed`
render(
<ExtensionStateWrapper>
<CommandExecution
executionId="test-10"
text={commandWithMixedContent}
icon={<span>icon</span>}
title={<span>Run Command</span>}
/>
</ExtensionStateWrapper>,
)
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show the command in the selector
expect(selector).toHaveTextContent("npm test")
})
it("should update both allowed and denied lists when commands conflict", () => {
const conflictState = {
...mockExtensionState,
allowedCommands: ["git"],
deniedCommands: ["git push origin main"],
}
render(
<ExtensionStateContext.Provider value={conflictState as any}>
<CommandExecution executionId="test-11" text="git push origin main" />
</ExtensionStateContext.Provider>,
)
// Click to allow "git push origin main"
const allowButton = screen.getByText("Allow")
fireEvent.click(allowButton)
// Should add to allowed and remove from denied
expect(conflictState.setAllowedCommands).toHaveBeenCalledWith(["git", "git push origin main"])
expect(conflictState.setDeniedCommands).toHaveBeenCalledWith([])
})
it("should handle commands with special quotes", () => {
// Test with a command that has quotes
const commandWithQuotes = "echo 'test with unclosed quote"
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-12" text={commandWithQuotes} />
</ExtensionStateWrapper>,
)
// Should still render the command
expect(screen.getByTestId("code-block")).toHaveTextContent("echo 'test with unclosed quote")
// Should show pattern selector with a command pattern
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
expect(selector.textContent).toMatch(/echo/)
})
it("should handle empty or whitespace-only commands", () => {
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-13" text=" " />
</ExtensionStateWrapper>,
)
// Should render without errors
expect(screen.getByTestId("code-block")).toBeInTheDocument()
// Should not show pattern selector for empty commands
expect(screen.queryByTestId("command-pattern-selector")).not.toBeInTheDocument()
})
it("should handle commands with only output and no command prefix", () => {
const outputOnly = `Some output without a command
Multiple lines of output
Without any command prefix`
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-14" text={outputOnly} />
</ExtensionStateWrapper>,
)
// Should treat the entire text as command when no prefix is found
const codeBlock = screen.getByTestId("code-block")
// The mock CodeBlock component renders text content without preserving newlines
expect(codeBlock.textContent).toContain("Some output without a command")
expect(codeBlock.textContent).toContain("Multiple lines of output")
expect(codeBlock.textContent).toContain("Without any command prefix")
})
it("should handle simple commands", () => {
const plainCommand = "docker build ."
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-15" text={plainCommand} />
</ExtensionStateWrapper>,
)
// Should render the command
expect(screen.getByTestId("code-block")).toHaveTextContent("docker build .")
// Should show pattern selector with the full command
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
expect(selector).toHaveTextContent("docker build .")
// Verify no output is shown (since there's no Output: separator)
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks).toHaveLength(1) // Only the command block, no output block
})
it("should handle commands with numeric output", () => {
const commandWithNumericOutput = `wc -l *.go *.java
Output:
10 file1.go
20 file2.go
15 Main.java
45 total`
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-16" text={commandWithNumericOutput} />
</ExtensionStateWrapper>,
)
// Should render the command and output
const codeBlocks = screen.getAllByTestId("code-block")
expect(codeBlocks[0]).toHaveTextContent("wc -l *.go *.java")
// Should show pattern selector
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show a command pattern
expect(selector.textContent).toMatch(/wc/)
// The output should still be displayed
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toBeInTheDocument()
expect(terminalOutput.textContent).toContain("45 total")
})
it("should handle commands with zero output", () => {
const commandWithZeroTotal = `wc -l *.go *.java
Output:
0 total`
render(
<ExtensionStateWrapper>
<CommandExecution executionId="test-17" text={commandWithZeroTotal} />
</ExtensionStateWrapper>,
)
// Should show pattern selector
const selector = screen.getByTestId("command-pattern-selector")
expect(selector).toBeInTheDocument()
// Should show a command pattern
expect(selector.textContent).toMatch(/wc/)
// The output should still be displayed
const terminalOutput = screen.getByTestId("terminal-output")
expect(terminalOutput).toBeInTheDocument()
expect(terminalOutput).toHaveTextContent("0 total")
})
})
})