2
0

TerminalProcessExec.bash.test.ts 13 KB

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