| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- // src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts
- import * as vscode from "vscode"
- import { execSync } from "child_process"
- import { TerminalProcess, ExitCodeDetails } from "../TerminalProcess"
- import { Terminal } from "../Terminal"
- import { TerminalRegistry } from "../TerminalRegistry"
- // Mock the vscode module
- jest.mock("vscode", () => {
- // Store event handlers so we can trigger them in tests
- const eventHandlers = {
- startTerminalShellExecution: null,
- endTerminalShellExecution: null,
- }
- return {
- workspace: {
- getConfiguration: jest.fn().mockReturnValue({
- get: jest.fn().mockReturnValue(null),
- }),
- },
- window: {
- createTerminal: jest.fn(),
- onDidStartTerminalShellExecution: jest.fn().mockImplementation((handler) => {
- eventHandlers.startTerminalShellExecution = handler
- return { dispose: jest.fn() }
- }),
- onDidEndTerminalShellExecution: jest.fn().mockImplementation((handler) => {
- eventHandlers.endTerminalShellExecution = handler
- return { dispose: jest.fn() }
- }),
- },
- ThemeIcon: class ThemeIcon {
- constructor(id: string) {
- this.id = id
- }
- id: string
- },
- Uri: {
- file: (path: string) => ({ fsPath: path }),
- },
- // Expose event handlers for testing
- __eventHandlers: eventHandlers,
- }
- })
- // Create a mock stream that uses real command output with realistic chunking
- function createRealCommandStream(command: string): { stream: AsyncIterable<string>; exitCode: number } {
- let realOutput: string
- let exitCode: number
- try {
- // Execute the command and get the real output, redirecting stderr to /dev/null
- realOutput = execSync(command + " 2>/dev/null", {
- encoding: "utf8",
- maxBuffer: 100 * 1024 * 1024, // Increase buffer size to 100MB
- })
- exitCode = 0 // Command succeeded
- } catch (error: any) {
- // Command failed - get output and exit code from error
- realOutput = error.stdout?.toString() || ""
- // Handle signal termination
- if (error.signal) {
- // Convert signal name to number using Node's constants
- const signals: Record<string, number> = {
- SIGTERM: 15,
- SIGSEGV: 11,
- // Add other signals as needed
- }
- const signalNum = signals[error.signal]
- if (signalNum !== undefined) {
- exitCode = 128 + signalNum // Signal exit codes are 128 + signal number
- } else {
- // Log error and default to 1 if signal not recognized
- console.log(`[DEBUG] Unrecognized signal '${error.signal}' from command '${command}'`)
- exitCode = 1
- }
- } else {
- exitCode = error.status || 1 // Use status if available, default to 1
- }
- }
- // Create an async iterator that yields the command output with proper markers
- // and realistic chunking (not guaranteed to split on newlines)
- const stream = {
- async *[Symbol.asyncIterator]() {
- // First yield the command start marker
- yield "\x1b]633;C\x07"
- // Yield the real output in potentially arbitrary chunks
- // This simulates how terminal data might be received in practice
- if (realOutput.length > 0) {
- // For a simple test like "echo a", we'll just yield the whole output
- // For more complex outputs, we could implement random chunking here
- yield realOutput
- }
- // Last yield the command end marker
- yield "\x1b]633;D\x07"
- },
- }
- return { stream, exitCode }
- }
- /**
- * Generalized function to test terminal command execution
- * @param command The command to execute
- * @param expectedOutput The expected output after processing
- * @returns A promise that resolves when the test is complete
- */
- async function testTerminalCommand(
- command: string,
- expectedOutput: string,
- ): Promise<{ executionTimeUs: number; capturedOutput: string; exitDetails: ExitCodeDetails }> {
- let startTime: bigint = BigInt(0)
- let endTime: bigint = BigInt(0)
- let timeRecorded = false
- let timeoutId: NodeJS.Timeout | undefined
- // Create a mock terminal with shell integration
- const mockTerminal = {
- shellIntegration: {
- executeCommand: jest.fn(),
- cwd: vscode.Uri.file("/test/path"),
- },
- name: "Roo Code",
- processId: Promise.resolve(123),
- creationOptions: {},
- exitStatus: undefined,
- state: { isInteractedWith: true },
- dispose: jest.fn(),
- hide: jest.fn(),
- show: jest.fn(),
- sendText: jest.fn(),
- }
- // Create terminal info with running state
- const mockTerminalInfo = new Terminal(1, mockTerminal, "/test/path")
- mockTerminalInfo.running = true
- // Add the terminal to the registry
- TerminalRegistry["terminals"] = [mockTerminalInfo]
- // Create a new terminal process for testing
- startTime = process.hrtime.bigint() // Start timing from terminal process creation
- const terminalProcess = new TerminalProcess(mockTerminalInfo)
- try {
- // Set up the mock stream with real command output and exit code
- const { stream, exitCode } = createRealCommandStream(command)
- // Configure the mock terminal to return our stream
- mockTerminal.shellIntegration.executeCommand.mockImplementation(() => {
- return {
- read: jest.fn().mockReturnValue(stream),
- }
- })
- // Set up event listeners to capture output
- let capturedOutput = ""
- terminalProcess.on("completed", (output) => {
- if (!timeRecorded) {
- endTime = process.hrtime.bigint() // End timing when completed event is received with output
- timeRecorded = true
- }
- if (output) {
- capturedOutput = output
- }
- })
- // Create a promise that resolves when the command completes
- const completedPromise = new Promise<void>((resolve) => {
- terminalProcess.once("completed", () => {
- resolve()
- })
- })
- // Set the process on the terminal
- mockTerminalInfo.process = terminalProcess
- // Run the command (now handled by constructor)
- // We've already created the process, so we'll trigger the events manually
- // Get the event handlers from the mock
- const eventHandlers = (vscode as any).__eventHandlers
- // Execute the command first to set up the process
- terminalProcess.run(command)
- // Trigger the start terminal shell execution event through VSCode mock
- if (eventHandlers.startTerminalShellExecution) {
- eventHandlers.startTerminalShellExecution({
- terminal: mockTerminal,
- execution: {
- commandLine: { value: command },
- read: () => stream,
- },
- })
- }
- // Wait for some output to be processed
- await new Promise<void>((resolve) => {
- terminalProcess.once("line", () => resolve())
- })
- // Then trigger the end event
- if (eventHandlers.endTerminalShellExecution) {
- eventHandlers.endTerminalShellExecution({
- terminal: mockTerminal,
- exitCode: exitCode,
- })
- }
- // Store exit details for return
- const exitDetails = TerminalProcess.interpretExitCode(exitCode)
- // Set a timeout to avoid hanging tests
- let timeoutId: NodeJS.Timeout
- const timeoutPromise = new Promise<void>((_, reject) => {
- timeoutId = setTimeout(() => {
- reject(new Error("Test timed out after 1000ms"))
- }, 1000)
- })
- // Wait for the command to complete or timeout
- await Promise.race([completedPromise, timeoutPromise])
- // Calculate execution time in microseconds
- // If endTime wasn't set (unlikely but possible), set it now
- if (!timeRecorded) {
- endTime = process.hrtime.bigint()
- }
- const executionTimeUs = Number((endTime - startTime) / BigInt(1000))
- // Verify the output matches the expected output
- expect(capturedOutput).toBe(expectedOutput)
- return { executionTimeUs, capturedOutput, exitDetails }
- } finally {
- // Clean up
- terminalProcess.removeAllListeners()
- TerminalRegistry["terminals"] = []
- // Clear the timeout if it exists
- if (timeoutId) {
- clearTimeout(timeoutId)
- }
- // Ensure we don't have any lingering timeouts
- // This is a safety measure in case the test exits before the timeout is cleared
- if (typeof global.gc === "function") {
- global.gc() // Force garbage collection if available
- }
- }
- }
- // Import the test purposes from the common file
- import { TEST_PURPOSES, LARGE_OUTPUT_PARAMS, TEST_TEXT } from "./TerminalProcessExec.common"
- describe("TerminalProcess with Bash Command Output", () => {
- beforeAll(() => {
- // Initialize TerminalRegistry event handlers once globally
- TerminalRegistry.initialize()
- })
- beforeEach(() => {
- // Reset the terminals array before each test
- TerminalRegistry["terminals"] = []
- jest.clearAllMocks()
- })
- // Each test uses Bash-specific commands to test the same functionality
- it(TEST_PURPOSES.BASIC_OUTPUT, async () => {
- const { executionTimeUs, capturedOutput } = await testTerminalCommand("echo a", "a\n")
- console.log(`'echo a' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} ms)`)
- expect(capturedOutput).toBe("a\n")
- })
- it(TEST_PURPOSES.OUTPUT_WITHOUT_NEWLINE, async () => {
- // Bash command for output without newline
- const { executionTimeUs } = await testTerminalCommand("/bin/echo -n a", "a")
- console.log(`'echo -n a' execution time: ${executionTimeUs} microseconds`)
- })
- it(TEST_PURPOSES.MULTILINE_OUTPUT, async () => {
- const expectedOutput = "a\nb\n"
- // Bash multiline command using printf
- const { executionTimeUs } = await testTerminalCommand('printf "a\\nb\\n"', expectedOutput)
- console.log(`Multiline command execution time: ${executionTimeUs} microseconds`)
- })
- it(TEST_PURPOSES.EXIT_CODE_SUCCESS, async () => {
- // Success exit code
- const { exitDetails } = await testTerminalCommand("exit 0", "")
- expect(exitDetails).toEqual({ exitCode: 0 })
- })
- it(TEST_PURPOSES.EXIT_CODE_ERROR, async () => {
- // Error exit code
- const { exitDetails } = await testTerminalCommand("exit 1", "")
- expect(exitDetails).toEqual({ exitCode: 1 })
- })
- it(TEST_PURPOSES.EXIT_CODE_CUSTOM, async () => {
- // Custom exit code
- const { exitDetails } = await testTerminalCommand("exit 2", "")
- expect(exitDetails).toEqual({ exitCode: 2 })
- })
- it(TEST_PURPOSES.COMMAND_NOT_FOUND, async () => {
- // Test a non-existent command
- const { exitDetails } = await testTerminalCommand("nonexistentcommand", "")
- expect(exitDetails?.exitCode).toBe(127) // Command not found exit code in bash
- })
- it(TEST_PURPOSES.CONTROL_SEQUENCES, async () => {
- // Use printf instead of echo -e for more consistent behavior across platforms
- const { capturedOutput } = await testTerminalCommand(
- 'printf "\\033[31mRed Text\\033[0m\\n"',
- "\x1B[31mRed Text\x1B[0m\n",
- )
- expect(capturedOutput).toBe("\x1B[31mRed Text\x1B[0m\n")
- })
- it(TEST_PURPOSES.LARGE_OUTPUT, async () => {
- // Generate a larger output stream
- const lines = LARGE_OUTPUT_PARAMS.LINES
- const command = `for i in $(seq 1 ${lines}); do echo "${TEST_TEXT.LARGE_PREFIX}$i"; done`
- // Build expected output
- const expectedOutput =
- Array.from({ length: lines }, (_, i) => `${TEST_TEXT.LARGE_PREFIX}${i + 1}`).join("\n") + "\n"
- const { executionTimeUs, capturedOutput } = await testTerminalCommand(command, expectedOutput)
- // Verify a sample of the output
- const outputLines = capturedOutput.split("\n")
- // Check if we have the expected number of lines
- expect(outputLines.length - 1).toBe(lines) // -1 for trailing newline
- console.log(`Large output command (${lines} lines) execution time: ${executionTimeUs} microseconds`)
- })
- it(TEST_PURPOSES.SIGNAL_TERMINATION, async () => {
- // Run kill in subshell to ensure signal affects the command
- const { exitDetails } = await testTerminalCommand("bash -c 'kill $$'", "")
- expect(exitDetails).toEqual({
- exitCode: 143, // 128 + 15 (SIGTERM)
- signal: 15,
- signalName: "SIGTERM",
- coreDumpPossible: false,
- })
- })
- it(TEST_PURPOSES.SIGNAL_SEGV, async () => {
- // Run kill in subshell to ensure signal affects the command
- const { exitDetails } = await testTerminalCommand("bash -c 'kill -SIGSEGV $$'", "")
- expect(exitDetails).toEqual({
- exitCode: 139, // 128 + 11 (SIGSEGV)
- signal: 11,
- signalName: "SIGSEGV",
- coreDumpPossible: true,
- })
- })
- // We can skip this very large test for normal development
- it.skip(`should execute 'yes AAA... | head -n ${1_000_000}' and verify lines of 'A's`, async () => {
- const TEST_LINES = 1_000_000
- const expectedOutput = Array(TEST_LINES).fill("A".repeat(76)).join("\n") + "\n"
- // This command will generate 1M lines with 76 'A's each.
- const { executionTimeUs, capturedOutput } = await testTerminalCommand(
- `yes "${"A".repeat(76)}" | head -n ${TEST_LINES}`,
- expectedOutput,
- )
- console.log(
- `'yes "${"A".repeat(76)}" | head -n ${TEST_LINES}' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
- )
- // Display a truncated output sample (first 3 lines and last 3 lines)
- const lines = capturedOutput.split("\n")
- const truncatedOutput =
- lines.slice(0, 3).join("\n") +
- `\n... (truncated ${lines.length - 6} lines) ...\n` +
- lines.slice(Math.max(0, lines.length - 3), lines.length).join("\n")
- console.log("Output sample (first 3 lines):\n", truncatedOutput)
- // Verify the output.
- // Check if we have TEST_LINES lines (may have an empty line at the end).
- expect(lines.length).toBeGreaterThanOrEqual(TEST_LINES)
- // Sample some lines to verify they contain 76 'A' characters.
- // Sample indices at beginning, 1%, 10%, 50%, and end of the output.
- const sampleIndices = [
- 0,
- Math.floor(TEST_LINES * 0.01),
- Math.floor(TEST_LINES * 0.1),
- Math.floor(TEST_LINES * 0.5),
- TEST_LINES - 1,
- ].filter((i) => i < lines.length)
- for (const index of sampleIndices) {
- expect(lines[index]).toBe("A".repeat(76))
- }
- })
- })
|