diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 370bf0d377b..c133b9085fb 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -129,7 +129,19 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { } } - this.emit("shell_execution_complete", { exitCode: 0 }) + // Await the subprocess to get the actual exit code + // Stream iteration completes successfully regardless of exit code + try { + const result = await this.subprocess + this.emit("shell_execution_complete", { exitCode: result?.exitCode ?? 0 }) + } catch (error) { + // Handle case where subprocess threw during await + if (error instanceof ExecaError) { + this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 1, signalName: error.signal }) + } else { + this.emit("shell_execution_complete", { exitCode: 1 }) + } + } } catch (error) { if (error instanceof ExecaError) { console.error(`[ExecaTerminalProcess#run] shell execution error: ${error.message}`) diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index c87ee5ad05d..7ea126824fc 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -1,20 +1,46 @@ // npx vitest run integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts const mockPid = 12345 +let mockExitCode = 0 +let mockShouldThrow = false +let mockErrorSignal: string | undefined vitest.mock("execa", () => { const mockKill = vitest.fn() + class MockExecaError extends Error { + exitCode: number + signal?: string + constructor(message: string, exitCode: number, signal?: string) { + super(message) + this.exitCode = exitCode + this.signal = signal + } + } const execa = vitest.fn((options: any) => { - return (_template: TemplateStringsArray, ...args: any[]) => ({ - pid: mockPid, - iterable: (_opts: any) => - (async function* () { - yield "test output\n" - })(), - kill: mockKill, - }) + return (_template: TemplateStringsArray, ...args: any[]) => { + // Create a promise that resolves/rejects based on mockShouldThrow + const resultPromise = new Promise((resolve, reject) => { + // Use setImmediate to allow the iterable to be consumed first + setImmediate(() => { + if (mockShouldThrow) { + reject(new MockExecaError("Command failed", mockExitCode, mockErrorSignal)) + } else { + resolve({ exitCode: mockExitCode }) + } + }) + }) + + return Object.assign(resultPromise, { + pid: mockPid, + iterable: (_opts: any) => + (async function* () { + yield "test output\n" + })(), + kill: mockKill, + }) + } }) - return { execa, ExecaError: class extends Error {} } + return { execa, ExecaError: MockExecaError } }) vitest.mock("ps-tree", () => ({ @@ -31,6 +57,11 @@ describe("ExecaTerminalProcess", () => { let originalEnv: NodeJS.ProcessEnv beforeEach(() => { + // Reset mock state + mockExitCode = 0 + mockShouldThrow = false + mockErrorSignal = undefined + originalEnv = { ...process.env } mockTerminal = { provider: "execa", @@ -163,4 +194,40 @@ describe("ExecaTerminalProcess", () => { expect(terminalProcess["lastRetrievedIndex"]).toBe(0) }) }) + + describe("exit code handling", () => { + it("should emit shell_execution_complete with non-zero exit code when command fails", async () => { + mockExitCode = 1 + const spy = vitest.fn() + terminalProcess.on("shell_execution_complete", spy) + await terminalProcess.run("exit 1") + expect(spy).toHaveBeenCalledWith({ exitCode: 1 }) + }) + + it("should emit shell_execution_complete with specific non-zero exit code", async () => { + mockExitCode = 127 + const spy = vitest.fn() + terminalProcess.on("shell_execution_complete", spy) + await terminalProcess.run("nonexistent_command") + expect(spy).toHaveBeenCalledWith({ exitCode: 127 }) + }) + + it("should handle ExecaError thrown during await with exit code and signal", async () => { + mockShouldThrow = true + mockExitCode = 128 + mockErrorSignal = "SIGKILL" + const spy = vitest.fn() + terminalProcess.on("shell_execution_complete", spy) + await terminalProcess.run("killed_command") + expect(spy).toHaveBeenCalledWith({ exitCode: 128, signalName: "SIGKILL" }) + }) + + it("should emit exitCode 0 when command succeeds", async () => { + mockExitCode = 0 + const spy = vitest.fn() + terminalProcess.on("shell_execution_complete", spy) + await terminalProcess.run("echo success") + expect(spy).toHaveBeenCalledWith({ exitCode: 0 }) + }) + }) })