TerminalProcessExec.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. // npx jest src/integrations/terminal/__tests__/TerminalProcessExec.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 as ((e: any) => void) | null,
  12. endTerminalShellExecution: null as ((e: any) => void) | 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. // Create a mock terminal with shell integration
  111. const mockTerminal = {
  112. shellIntegration: {
  113. executeCommand: jest.fn(),
  114. cwd: vscode.Uri.file("/test/path"),
  115. },
  116. name: "Roo Code",
  117. processId: Promise.resolve(123),
  118. creationOptions: {},
  119. exitStatus: undefined,
  120. state: { isInteractedWith: true },
  121. dispose: jest.fn(),
  122. hide: jest.fn(),
  123. show: jest.fn(),
  124. sendText: jest.fn(),
  125. }
  126. // Create terminal info with running state
  127. const mockTerminalInfo = new Terminal(1, mockTerminal, "/test/path")
  128. mockTerminalInfo.running = true
  129. // Add the terminal to the registry
  130. TerminalRegistry["terminals"] = [mockTerminalInfo]
  131. // Create a new terminal process for testing
  132. startTime = process.hrtime.bigint() // Start timing from terminal process creation
  133. const terminalProcess = new TerminalProcess(mockTerminalInfo)
  134. try {
  135. // Set up the mock stream with real command output and exit code
  136. const { stream, exitCode } = createRealCommandStream(command)
  137. // Configure the mock terminal to return our stream
  138. mockTerminal.shellIntegration.executeCommand.mockImplementation(() => {
  139. return {
  140. read: jest.fn().mockReturnValue(stream),
  141. }
  142. })
  143. // Set up event listeners to capture output
  144. let capturedOutput = ""
  145. terminalProcess.on("completed", (output) => {
  146. if (!timeRecorded) {
  147. endTime = process.hrtime.bigint() // End timing when completed event is received with output
  148. timeRecorded = true
  149. }
  150. if (output) {
  151. capturedOutput = output
  152. }
  153. })
  154. // Create a promise that resolves when the command completes
  155. const completedPromise = new Promise<void>((resolve) => {
  156. terminalProcess.once("completed", () => {
  157. resolve()
  158. })
  159. })
  160. // Set the process on the terminal
  161. mockTerminalInfo.process = terminalProcess
  162. // Run the command (now handled by constructor)
  163. // We've already created the process, so we'll trigger the events manually
  164. // Get the event handlers from the mock
  165. const eventHandlers = (vscode as any).__eventHandlers
  166. // Execute the command first to set up the process
  167. terminalProcess.run(command)
  168. // Trigger the start terminal shell execution event through VSCode mock
  169. if (eventHandlers.startTerminalShellExecution) {
  170. eventHandlers.startTerminalShellExecution({
  171. terminal: mockTerminal,
  172. execution: {
  173. commandLine: { value: command },
  174. read: () => stream,
  175. },
  176. })
  177. }
  178. // Wait for some output to be processed
  179. await new Promise<void>((resolve) => {
  180. terminalProcess.once("line", () => resolve())
  181. })
  182. // Then trigger the end event
  183. if (eventHandlers.endTerminalShellExecution) {
  184. eventHandlers.endTerminalShellExecution({
  185. terminal: mockTerminal,
  186. exitCode: exitCode,
  187. })
  188. }
  189. // Store exit details for return
  190. const exitDetails = TerminalProcess.interpretExitCode(exitCode)
  191. // Set a timeout to avoid hanging tests
  192. const timeoutPromise = new Promise<void>((_, reject) => {
  193. setTimeout(() => {
  194. reject(new Error("Test timed out after 1000ms"))
  195. }, 1000)
  196. })
  197. // Wait for the command to complete or timeout
  198. await Promise.race([completedPromise, timeoutPromise])
  199. // Calculate execution time in microseconds
  200. // If endTime wasn't set (unlikely but possible), set it now
  201. if (!timeRecorded) {
  202. endTime = process.hrtime.bigint()
  203. }
  204. const executionTimeUs = Number((endTime - startTime) / BigInt(1000))
  205. // Verify the output matches the expected output
  206. expect(capturedOutput).toBe(expectedOutput)
  207. return { executionTimeUs, capturedOutput, exitDetails }
  208. } finally {
  209. // Clean up
  210. terminalProcess.removeAllListeners()
  211. TerminalRegistry["terminals"] = []
  212. }
  213. }
  214. describe("TerminalProcess with Real Command Output", () => {
  215. beforeAll(() => {
  216. // Initialize TerminalRegistry event handlers once globally
  217. TerminalRegistry.initialize()
  218. })
  219. beforeEach(() => {
  220. // Reset the terminals array before each test
  221. TerminalRegistry["terminals"] = []
  222. jest.clearAllMocks()
  223. })
  224. it("should execute 'echo a' and return exactly 'a\\n' with execution time", async () => {
  225. const { executionTimeUs, capturedOutput } = await testTerminalCommand("echo a", "a\n")
  226. })
  227. it("should execute 'echo -n a' and return exactly 'a'", async () => {
  228. const { executionTimeUs } = await testTerminalCommand("/bin/echo -n a", "a")
  229. console.log(
  230. `'echo -n a' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
  231. )
  232. })
  233. it("should execute 'printf \"a\\nb\\n\"' and return 'a\\nb\\n'", async () => {
  234. const { executionTimeUs } = await testTerminalCommand('printf "a\\nb\\n"', "a\nb\n")
  235. console.log(
  236. `'printf "a\\nb\\n"' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
  237. )
  238. })
  239. it("should properly handle terminal shell execution events", async () => {
  240. // This test is implicitly testing the event handlers since all tests now use them
  241. const { executionTimeUs } = await testTerminalCommand("echo test", "test\n")
  242. console.log(
  243. `'echo test' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
  244. )
  245. })
  246. const TEST_LINES = 1_000_000
  247. it(`should execute 'yes AAA... | head -n ${TEST_LINES}' and verify ${TEST_LINES} lines of 'A's`, async () => {
  248. const expectedOutput = Array(TEST_LINES).fill("A".repeat(76)).join("\n") + "\n"
  249. // This command will generate 1M lines with 76 'A's each.
  250. const { executionTimeUs, capturedOutput } = await testTerminalCommand(
  251. `yes "${"A".repeat(76)}" | head -n ${TEST_LINES}`,
  252. expectedOutput,
  253. )
  254. console.log(
  255. `'yes "${"A".repeat(76)}" | head -n ${TEST_LINES}' execution time: ${executionTimeUs} microseconds (${executionTimeUs / 1000} milliseconds)`,
  256. )
  257. // Display a truncated output sample (first 3 lines and last 3 lines)
  258. const lines = capturedOutput.split("\n")
  259. const truncatedOutput =
  260. lines.slice(0, 3).join("\n") +
  261. `\n... (truncated ${lines.length - 6} lines) ...\n` +
  262. lines.slice(Math.max(0, lines.length - 3), lines.length).join("\n")
  263. console.log("Output sample (first 3 lines):\n", truncatedOutput)
  264. // Verify the output.
  265. // Check if we have TEST_LINES lines (may have an empty line at the end).
  266. expect(lines.length).toBeGreaterThanOrEqual(TEST_LINES)
  267. // Sample some lines to verify they contain 76 'A' characters.
  268. // Sample indices at beginning, 1%, 10%, 50%, and end of the output.
  269. const sampleIndices = [
  270. 0,
  271. Math.floor(TEST_LINES * 0.01),
  272. Math.floor(TEST_LINES * 0.1),
  273. Math.floor(TEST_LINES * 0.5),
  274. TEST_LINES - 1,
  275. ].filter((i) => i < lines.length)
  276. for (const index of sampleIndices) {
  277. expect(lines[index]).toBe("A".repeat(76))
  278. }
  279. })
  280. describe("exit code interpretation", () => {
  281. it("should handle exit 2", async () => {
  282. const { exitDetails } = await testTerminalCommand("exit 2", "")
  283. expect(exitDetails).toEqual({ exitCode: 2 })
  284. })
  285. it("should handle normal exit codes", async () => {
  286. // Test successful command
  287. const { exitDetails } = await testTerminalCommand("true", "")
  288. expect(exitDetails).toEqual({ exitCode: 0 })
  289. // Test failed command
  290. const { exitDetails: exitDetails2 } = await testTerminalCommand("false", "")
  291. expect(exitDetails2).toEqual({ exitCode: 1 })
  292. })
  293. it("should interpret SIGTERM exit code", async () => {
  294. // Run kill in subshell to ensure signal affects the command
  295. const { exitDetails } = await testTerminalCommand("bash -c 'kill $$'", "")
  296. expect(exitDetails).toEqual({
  297. exitCode: 143, // 128 + 15 (SIGTERM)
  298. signal: 15,
  299. signalName: "SIGTERM",
  300. coreDumpPossible: false,
  301. })
  302. })
  303. it("should interpret SIGSEGV exit code", async () => {
  304. // Run kill in subshell to ensure signal affects the command
  305. const { exitDetails } = await testTerminalCommand("bash -c 'kill -SIGSEGV $$'", "")
  306. expect(exitDetails).toEqual({
  307. exitCode: 139, // 128 + 11 (SIGSEGV)
  308. signal: 11,
  309. signalName: "SIGSEGV",
  310. coreDumpPossible: true,
  311. })
  312. })
  313. it("should handle command not found", async () => {
  314. // Test a non-existent command
  315. const { exitDetails } = await testTerminalCommand("nonexistentcommand", "")
  316. expect(exitDetails?.exitCode).toBe(127) // Command not found
  317. })
  318. })
  319. })