TerminalProcessExec.bash.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. // src/integrations/terminal/__tests__/TerminalProcessExec.bash.test.ts
  2. import * as vscode from "vscode"
  3. import { execSync } from "child_process"
  4. import { TerminalProcess, ExitCodeDetails } from "../TerminalProcess"
  5. import { Terminal } from "../Terminal"
  6. import { TerminalRegistry } from "../TerminalRegistry"
  7. // Mock the vscode module
  8. jest.mock("vscode", () => {
  9. // Store event handlers so we can trigger them in tests
  10. const eventHandlers = {
  11. startTerminalShellExecution: null,
  12. endTerminalShellExecution: null,
  13. closeTerminal: null,
  14. }
  15. return {
  16. workspace: {
  17. getConfiguration: jest.fn().mockReturnValue({
  18. get: jest.fn().mockReturnValue(null),
  19. }),
  20. },
  21. window: {
  22. createTerminal: jest.fn(),
  23. onDidStartTerminalShellExecution: jest.fn().mockImplementation((handler) => {
  24. eventHandlers.startTerminalShellExecution = handler
  25. return { dispose: jest.fn() }
  26. }),
  27. onDidEndTerminalShellExecution: jest.fn().mockImplementation((handler) => {
  28. eventHandlers.endTerminalShellExecution = handler
  29. return { dispose: jest.fn() }
  30. }),
  31. onDidCloseTerminal: jest.fn().mockImplementation((handler) => {
  32. eventHandlers.closeTerminal = handler
  33. return { dispose: jest.fn() }
  34. }),
  35. },
  36. ThemeIcon: class ThemeIcon {
  37. constructor(id: string) {
  38. this.id = id
  39. }
  40. id: string
  41. },
  42. Uri: {
  43. file: (path: string) => ({ fsPath: path }),
  44. },
  45. // Expose event handlers for testing
  46. __eventHandlers: eventHandlers,
  47. }
  48. })
  49. // Create a mock stream that uses real command output with realistic chunking
  50. function createRealCommandStream(command: string): { stream: AsyncIterable<string>; exitCode: number } {
  51. let realOutput: string
  52. let exitCode: number
  53. try {
  54. // Execute the command and get the real output, redirecting stderr to /dev/null
  55. realOutput = execSync(command + " 2>/dev/null", {
  56. encoding: "utf8",
  57. maxBuffer: 100 * 1024 * 1024, // Increase buffer size to 100MB
  58. })
  59. exitCode = 0 // Command succeeded
  60. } catch (error: any) {
  61. // Command failed - get output and exit code from error
  62. realOutput = error.stdout?.toString() || ""
  63. // Handle signal termination
  64. if (error.signal) {
  65. // Convert signal name to number using Node's constants
  66. const signals: Record<string, number> = {
  67. SIGTERM: 15,
  68. SIGSEGV: 11,
  69. // Add other signals as needed
  70. }
  71. const signalNum = signals[error.signal]
  72. if (signalNum !== undefined) {
  73. exitCode = 128 + signalNum // Signal exit codes are 128 + signal number
  74. } else {
  75. // Log error and default to 1 if signal not recognized
  76. console.log(`[DEBUG] Unrecognized signal '${error.signal}' from command '${command}'`)
  77. exitCode = 1
  78. }
  79. } else {
  80. exitCode = error.status || 1 // Use status if available, default to 1
  81. }
  82. }
  83. // Create an async iterator that yields the command output with proper markers
  84. // and realistic chunking (not guaranteed to split on newlines)
  85. const stream = {
  86. async *[Symbol.asyncIterator]() {
  87. // First yield the command start marker
  88. yield "\x1b]633;C\x07"
  89. // Yield the real output in potentially arbitrary chunks
  90. // This simulates how terminal data might be received in practice
  91. if (realOutput.length > 0) {
  92. // For a simple test like "echo a", we'll just yield the whole output
  93. // For more complex outputs, we could implement random chunking here
  94. yield realOutput
  95. }
  96. // Last yield the command end marker
  97. yield "\x1b]633;D\x07"
  98. },
  99. }
  100. return { stream, exitCode }
  101. }
  102. /**
  103. * Generalized function to test terminal command execution
  104. * @param command The command to execute
  105. * @param expectedOutput The expected output after processing
  106. * @returns A promise that resolves when the test is complete
  107. */
  108. async function testTerminalCommand(
  109. command: string,
  110. expectedOutput: string,
  111. ): Promise<{ executionTimeUs: number; capturedOutput: string; exitDetails: ExitCodeDetails }> {
  112. let startTime: bigint = BigInt(0)
  113. let endTime: bigint = BigInt(0)
  114. let timeRecorded = false
  115. let timeoutId: NodeJS.Timeout | undefined
  116. // Create a mock terminal with shell integration
  117. const mockTerminal = {
  118. shellIntegration: {
  119. executeCommand: jest.fn(),
  120. cwd: vscode.Uri.file("/test/path"),
  121. },
  122. name: "Roo Code",
  123. processId: Promise.resolve(123),
  124. creationOptions: {},
  125. exitStatus: undefined,
  126. state: { isInteractedWith: true },
  127. dispose: jest.fn(),
  128. hide: jest.fn(),
  129. show: jest.fn(),
  130. sendText: jest.fn(),
  131. }
  132. // Create terminal info with running state
  133. const mockTerminalInfo = new Terminal(1, mockTerminal, "/test/path")
  134. mockTerminalInfo.running = true
  135. // Add the terminal to the registry
  136. TerminalRegistry["terminals"] = [mockTerminalInfo]
  137. // Create a new terminal process for testing
  138. startTime = process.hrtime.bigint() // Start timing from terminal process creation
  139. const terminalProcess = new TerminalProcess(mockTerminalInfo)
  140. try {
  141. // Set up the mock stream with real command output and exit code
  142. const { stream, exitCode } = createRealCommandStream(command)
  143. // Configure the mock terminal to return our stream
  144. mockTerminal.shellIntegration.executeCommand.mockImplementation(() => {
  145. return {
  146. read: jest.fn().mockReturnValue(stream),
  147. }
  148. })
  149. // Set up event listeners to capture output
  150. let capturedOutput = ""
  151. terminalProcess.on("completed", (output) => {
  152. if (!timeRecorded) {
  153. endTime = process.hrtime.bigint() // End timing when completed event is received with output
  154. timeRecorded = true
  155. }
  156. if (output) {
  157. capturedOutput = output
  158. }
  159. })
  160. // Create a promise that resolves when the command completes
  161. const completedPromise = new Promise<void>((resolve) => {
  162. terminalProcess.once("completed", () => {
  163. resolve()
  164. })
  165. })
  166. // Set the process on the terminal
  167. mockTerminalInfo.process = terminalProcess
  168. // Run the command (now handled by constructor)
  169. // We've already created the process, so we'll trigger the events manually
  170. // Get the event handlers from the mock
  171. const eventHandlers = (vscode as any).__eventHandlers
  172. // Execute the command first to set up the process
  173. terminalProcess.run(command)
  174. // Trigger the start terminal shell execution event through VSCode mock
  175. if (eventHandlers.startTerminalShellExecution) {
  176. eventHandlers.startTerminalShellExecution({
  177. terminal: mockTerminal,
  178. execution: {
  179. commandLine: { value: command },
  180. read: () => stream,
  181. },
  182. })
  183. }
  184. // Wait for some output to be processed
  185. await new Promise<void>((resolve) => {
  186. terminalProcess.once("line", () => resolve())
  187. })
  188. // Then trigger the end event
  189. if (eventHandlers.endTerminalShellExecution) {
  190. eventHandlers.endTerminalShellExecution({
  191. terminal: mockTerminal,
  192. exitCode: exitCode,
  193. })
  194. }
  195. // Store exit details for return
  196. const exitDetails = TerminalProcess.interpretExitCode(exitCode)
  197. // Set a timeout to avoid hanging tests
  198. const timeoutPromise = new Promise<void>((_, reject) => {
  199. timeoutId = setTimeout(() => {
  200. reject(new Error("Test timed out after 1000ms"))
  201. }, 1000)
  202. })
  203. // Wait for the command to complete or timeout
  204. await Promise.race([completedPromise, timeoutPromise])
  205. // Calculate execution time in microseconds
  206. // If endTime wasn't set (unlikely but possible), set it now
  207. if (!timeRecorded) {
  208. endTime = process.hrtime.bigint()
  209. }
  210. const executionTimeUs = Number((endTime - startTime) / BigInt(1000))
  211. // Verify the output matches the expected output
  212. expect(capturedOutput).toBe(expectedOutput)
  213. return { executionTimeUs, capturedOutput, exitDetails }
  214. } finally {
  215. // Clean up
  216. terminalProcess.removeAllListeners()
  217. TerminalRegistry["terminals"] = []
  218. // Clear the timeout if it exists
  219. if (timeoutId) {
  220. clearTimeout(timeoutId)
  221. }
  222. // Ensure we don't have any lingering timeouts
  223. // This is a safety measure in case the test exits before the timeout is cleared
  224. if (typeof global.gc === "function") {
  225. global.gc() // Force garbage collection if available
  226. }
  227. }
  228. }
  229. // Import the test purposes from the common file
  230. import { TEST_PURPOSES, LARGE_OUTPUT_PARAMS, TEST_TEXT } from "./TerminalProcessExec.common"
  231. describe("TerminalProcess with Bash Command Output", () => {
  232. beforeAll(() => {
  233. // Initialize TerminalRegistry event handlers once globally
  234. TerminalRegistry.initialize()
  235. })
  236. beforeEach(() => {
  237. // Reset the terminals array before each test
  238. TerminalRegistry["terminals"] = []
  239. jest.clearAllMocks()
  240. })
  241. // Each test uses Bash-specific commands to test the same functionality
  242. it(TEST_PURPOSES.BASIC_OUTPUT, async () => {
  243. const { executionTimeUs, capturedOutput } = await testTerminalCommand("echo a", "a\n")
  244. console.log(`'echo a' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} ms)`)
  245. expect(capturedOutput).toBe("a\n")
  246. })
  247. it(TEST_PURPOSES.OUTPUT_WITHOUT_NEWLINE, async () => {
  248. // Bash command for output without newline
  249. const { executionTimeUs } = await testTerminalCommand("/bin/echo -n a", "a")
  250. console.log(`'echo -n a' execution time: ${executionTimeUs} microseconds`)
  251. })
  252. it(TEST_PURPOSES.MULTILINE_OUTPUT, async () => {
  253. const expectedOutput = "a\nb\n"
  254. // Bash multiline command using printf
  255. const { executionTimeUs } = await testTerminalCommand('printf "a\\nb\\n"', expectedOutput)
  256. console.log(`Multiline command execution time: ${executionTimeUs} microseconds`)
  257. })
  258. it(TEST_PURPOSES.EXIT_CODE_SUCCESS, async () => {
  259. // Success exit code
  260. const { exitDetails } = await testTerminalCommand("exit 0", "")
  261. expect(exitDetails).toEqual({ exitCode: 0 })
  262. })
  263. it(TEST_PURPOSES.EXIT_CODE_ERROR, async () => {
  264. // Error exit code
  265. const { exitDetails } = await testTerminalCommand("exit 1", "")
  266. expect(exitDetails).toEqual({ exitCode: 1 })
  267. })
  268. it(TEST_PURPOSES.EXIT_CODE_CUSTOM, async () => {
  269. // Custom exit code
  270. const { exitDetails } = await testTerminalCommand("exit 2", "")
  271. expect(exitDetails).toEqual({ exitCode: 2 })
  272. })
  273. it(TEST_PURPOSES.COMMAND_NOT_FOUND, async () => {
  274. // Test a non-existent command
  275. const { exitDetails } = await testTerminalCommand("nonexistentcommand", "")
  276. expect(exitDetails?.exitCode).toBe(127) // Command not found exit code in bash
  277. })
  278. it(TEST_PURPOSES.CONTROL_SEQUENCES, async () => {
  279. // Use printf instead of echo -e for more consistent behavior across platforms
  280. const { capturedOutput } = await testTerminalCommand(
  281. 'printf "\\033[31mRed Text\\033[0m\\n"',
  282. "\x1B[31mRed Text\x1B[0m\n",
  283. )
  284. expect(capturedOutput).toBe("\x1B[31mRed Text\x1B[0m\n")
  285. })
  286. it(TEST_PURPOSES.LARGE_OUTPUT, async () => {
  287. // Generate a larger output stream
  288. const lines = LARGE_OUTPUT_PARAMS.LINES
  289. const command = `for i in $(seq 1 ${lines}); do echo "${TEST_TEXT.LARGE_PREFIX}$i"; done`
  290. // Build expected output
  291. const expectedOutput =
  292. Array.from({ length: lines }, (_, i) => `${TEST_TEXT.LARGE_PREFIX}${i + 1}`).join("\n") + "\n"
  293. const { executionTimeUs, capturedOutput } = await testTerminalCommand(command, expectedOutput)
  294. // Verify a sample of the output
  295. const outputLines = capturedOutput.split("\n")
  296. // Check if we have the expected number of lines
  297. expect(outputLines.length - 1).toBe(lines) // -1 for trailing newline
  298. console.log(`Large output command (${lines} lines) execution time: ${executionTimeUs} microseconds`)
  299. })
  300. it(TEST_PURPOSES.SIGNAL_TERMINATION, async () => {
  301. // Run kill in subshell to ensure signal affects the command
  302. const { exitDetails } = await testTerminalCommand("bash -c 'kill $$'", "")
  303. expect(exitDetails).toEqual({
  304. exitCode: 143, // 128 + 15 (SIGTERM)
  305. signal: 15,
  306. signalName: "SIGTERM",
  307. coreDumpPossible: false,
  308. })
  309. })
  310. it(TEST_PURPOSES.SIGNAL_SEGV, async () => {
  311. // Run kill in subshell to ensure signal affects the command
  312. const { exitDetails } = await testTerminalCommand("bash -c 'kill -SIGSEGV $$'", "")
  313. expect(exitDetails).toEqual({
  314. exitCode: 139, // 128 + 11 (SIGSEGV)
  315. signal: 11,
  316. signalName: "SIGSEGV",
  317. coreDumpPossible: true,
  318. })
  319. })
  320. // We can skip this very large test for normal development
  321. it.skip(`should execute 'yes AAA... | head -n ${1_000_000}' and verify lines of 'A's`, async () => {
  322. const TEST_LINES = 1_000_000
  323. const expectedOutput = Array(TEST_LINES).fill("A".repeat(76)).join("\n") + "\n"
  324. // This command will generate 1M lines with 76 'A's each.
  325. const { executionTimeUs, capturedOutput } = await testTerminalCommand(
  326. `yes "${"A".repeat(76)}" | head -n ${TEST_LINES}`,
  327. expectedOutput,
  328. )
  329. console.log(
  330. `'yes "${"A".repeat(76)}" | head -n ${TEST_LINES}' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
  331. )
  332. // Display a truncated output sample (first 3 lines and last 3 lines)
  333. const lines = capturedOutput.split("\n")
  334. const truncatedOutput =
  335. lines.slice(0, 3).join("\n") +
  336. `\n... (truncated ${lines.length - 6} lines) ...\n` +
  337. lines.slice(Math.max(0, lines.length - 3), lines.length).join("\n")
  338. console.log("Output sample (first 3 lines):\n", truncatedOutput)
  339. // Verify the output.
  340. // Check if we have TEST_LINES lines (may have an empty line at the end).
  341. expect(lines.length).toBeGreaterThanOrEqual(TEST_LINES)
  342. // Sample some lines to verify they contain 76 'A' characters.
  343. // Sample indices at beginning, 1%, 10%, 50%, and end of the output.
  344. const sampleIndices = [
  345. 0,
  346. Math.floor(TEST_LINES * 0.01),
  347. Math.floor(TEST_LINES * 0.1),
  348. Math.floor(TEST_LINES * 0.5),
  349. TEST_LINES - 1,
  350. ].filter((i) => i < lines.length)
  351. for (const index of sampleIndices) {
  352. expect(lines[index]).toBe("A".repeat(76))
  353. }
  354. })
  355. })