From 60c14d8f5e07e0c431eac7b98b68ba8d9726e24a Mon Sep 17 00:00:00 2001 From: Eric Wheeler Date: Mon, 3 Mar 2025 20:34:00 -0800 Subject: [PATCH] test: add comprehensive terminal command execution testing This commit combines three related improvements to terminal testing: - Create a reusable function for testing terminal commands with real output - Update tests to properly invoke terminal shell execution handlers - Add microsecond timing to measure execution performance Key improvements: - Added testTerminalCommand function that takes command and expected output - Use child_process.execSync to run real commands and feed output into mock terminal stream - Properly trigger VSCode onDidStartTerminalShellExecution and onDidEndTerminalShellExecution events - Add timeout mechanism to prevent hanging tests - Measure execution time from terminal process creation to command completion - Display both microseconds and milliseconds in test output - Add test for base64 encoded zeros with configurable line count - Increase buffer size for execSync to handle large outputs - Limit output display to avoid cluttering the terminal Signed-off-by: Eric Wheeler --- .../__tests__/TerminalProcessExec.test.ts | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 src/integrations/terminal/__tests__/TerminalProcessExec.test.ts diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.test.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.test.ts new file mode 100644 index 0000000000..8378f289b7 --- /dev/null +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.test.ts @@ -0,0 +1,293 @@ +// npx jest src/integrations/terminal/__tests__/TerminalProcess.test.ts + +import * as vscode from "vscode" +import { execSync } from "child_process" +import { TerminalProcess } from "../TerminalProcess" +import { TerminalInfo, TerminalRegistry } from "../TerminalRegistry" +import { TerminalManager } from "../TerminalManager" + +// Mock the vscode module +jest.mock("vscode", () => { + // Store event handlers so we can trigger them in tests + const eventHandlers = { + startTerminalShellExecution: null as ((e: any) => void) | null, + endTerminalShellExecution: null as ((e: any) => void) | null, + } + + return { + 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) { + // Execute the command and get the real output + const realOutput = execSync(command, { + encoding: "utf8", + maxBuffer: 100 * 1024 * 1024, // Increase buffer size to 100MB + }) + + // Create an async iterator that yields the command output with proper markers + // and realistic chunking (not guaranteed to split on newlines) + return { + 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" + }, + } +} + +/** + * 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 }> { + let startTime: bigint = BigInt(0) + let endTime: bigint = BigInt(0) + let timeRecorded = false + // 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 + const mockTerminalInfo: TerminalInfo = { + terminal: mockTerminal, + busy: false, + lastCommand: "", + id: 1, + running: false, + streamClosed: false, + } + + // Add the terminal to the registry + TerminalRegistry["terminals"] = [mockTerminalInfo] + + // Create a new terminal process + startTime = process.hrtime.bigint() // Start timing from terminal process creation + const terminalProcess = new TerminalProcess() + + // Create a terminal manager (this will set up the event handlers) + const terminalManager = new TerminalManager() + + try { + // Set up the mock stream with real command output + const mockStream = createRealCommandStream(command) + + // Configure the mock terminal to return our stream + mockTerminal.shellIntegration.executeCommand.mockImplementation(() => { + return { + read: jest.fn().mockReturnValue(mockStream), + } + }) + + // 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((resolve) => { + terminalProcess.once("completed", () => { + resolve() + }) + }) + + // Store the process in the manager's processes map + // This is needed for the TerminalManager to find the process when events are triggered + terminalManager["processes"].set(mockTerminalInfo.id, terminalProcess) + terminalManager["terminalIds"].add(mockTerminalInfo.id) + + // Run the command + const runPromise = terminalProcess.run(mockTerminal, command) + + // Get the event handlers from the mock + const eventHandlers = (vscode as any).__eventHandlers + + // Trigger the start terminal shell execution event through VSCode mock + if (eventHandlers.startTerminalShellExecution) { + eventHandlers.startTerminalShellExecution({ + terminal: mockTerminal, + execution: { + commandLine: { value: command }, + read: () => mockStream, + }, + }) + } + + // Wait a short time to ensure stream processing has started + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Trigger the end terminal shell execution event through VSCode mock + if (eventHandlers.endTerminalShellExecution) { + eventHandlers.endTerminalShellExecution({ + terminal: mockTerminal, + exitCode: 0, + }) + } + + // Set a timeout to avoid hanging tests + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error("Test timed out after 1000ms")) + }, 1000) + }) + + // Wait for the command to complete or timeout + await Promise.race([completedPromise, timeoutPromise]) + + await runPromise + // 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 } + } finally { + // Clean up + terminalProcess.removeAllListeners() + terminalManager.disposeAll() + TerminalRegistry["terminals"] = [] + } +} + +describe("TerminalProcess with Real Command Output", () => { + beforeEach(() => { + // Reset the terminals array before each test + TerminalRegistry["terminals"] = [] + jest.clearAllMocks() + }) + + it("should execute 'echo a' and return exactly 'a\\n' with execution time", async () => { + const { executionTimeUs, capturedOutput } = await testTerminalCommand("echo a", "a\n") + console.log(`'echo a' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`) + }) + + it("should execute 'echo -n a' and return exactly 'a'", async () => { + const { executionTimeUs } = await testTerminalCommand("echo -n a", "a") + console.log( + `'echo -n a' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`, + ) + }) + + it("should execute 'echo -e \"a\\nb\"' and return 'a\\nb\\n'", async () => { + const { executionTimeUs } = await testTerminalCommand('echo -e "a\\nb"', "a\nb\n") + console.log( + `'echo -e "a\\nb"' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`, + ) + }) + + it("should properly handle terminal shell execution events", async () => { + // This test is implicitly testing the event handlers since all tests now use them + const { executionTimeUs } = await testTerminalCommand("echo test", "test\n") + console.log( + `'echo test' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`, + ) + }) + + // Configure the number of lines for the base64 test + const BASE64_TEST_LINES = 1000000 + + it(`should execute 'base64 < /dev/zero | head -n ${BASE64_TEST_LINES}' and verify ${BASE64_TEST_LINES} lines of 'A's`, async () => { + // Create an expected output pattern that matches what base64 produces + // Each line is 76 'A's followed by a newline + const expectedOutput = Array(BASE64_TEST_LINES).fill("A".repeat(76)).join("\n") + "\n" + + // This command will generate BASE64_TEST_LINES lines of base64 encoded zeros + // Each line will contain 76 'A' characters (base64 encoding of zeros) + const { executionTimeUs, capturedOutput } = await testTerminalCommand( + `base64 < /dev/zero | head -n ${BASE64_TEST_LINES}`, + expectedOutput, + ) + + console.log( + `'base64 < /dev/zero | head -n ${BASE64_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 BASE64_TEST_LINES lines (may have an empty line at the end) + expect(lines.length).toBeGreaterThanOrEqual(BASE64_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(BASE64_TEST_LINES * 0.01), + Math.floor(BASE64_TEST_LINES * 0.1), + Math.floor(BASE64_TEST_LINES * 0.5), + BASE64_TEST_LINES - 1, + ].filter((i) => i < lines.length) + for (const index of sampleIndices) { + expect(lines[index]).toBe("A".repeat(76)) + } + }) +})