Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 97f0aca

Browse files
committed
fix: correctly report exit codes from ExecaTerminalProcess
- Fix bug where ExecaTerminalProcess always emitted exitCode: 0 - After stream iteration, await subprocess to get actual exit code - Handle ExecaError thrown during await with exit code and signal - Add tests for non-zero exit code scenarios Fixes #11044
1 parent e7965d9 commit 97f0aca

2 files changed

Lines changed: 89 additions & 10 deletions

File tree

src/integrations/terminal/ExecaTerminalProcess.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,19 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
129129
}
130130
}
131131

132-
this.emit("shell_execution_complete", { exitCode: 0 })
132+
// Await the subprocess to get the actual exit code
133+
// Stream iteration completes successfully regardless of exit code
134+
try {
135+
const result = await this.subprocess
136+
this.emit("shell_execution_complete", { exitCode: result?.exitCode ?? 0 })
137+
} catch (error) {
138+
// Handle case where subprocess threw during await
139+
if (error instanceof ExecaError) {
140+
this.emit("shell_execution_complete", { exitCode: error.exitCode ?? 1, signalName: error.signal })
141+
} else {
142+
this.emit("shell_execution_complete", { exitCode: 1 })
143+
}
144+
}
133145
} catch (error) {
134146
if (error instanceof ExecaError) {
135147
console.error(`[ExecaTerminalProcess#run] shell execution error: ${error.message}`)

src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,46 @@
11
// npx vitest run integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts
22

33
const mockPid = 12345
4+
let mockExitCode = 0
5+
let mockShouldThrow = false
6+
let mockErrorSignal: string | undefined
47

58
vitest.mock("execa", () => {
69
const mockKill = vitest.fn()
10+
class MockExecaError extends Error {
11+
exitCode: number
12+
signal?: string
13+
constructor(message: string, exitCode: number, signal?: string) {
14+
super(message)
15+
this.exitCode = exitCode
16+
this.signal = signal
17+
}
18+
}
719
const execa = vitest.fn((options: any) => {
8-
return (_template: TemplateStringsArray, ...args: any[]) => ({
9-
pid: mockPid,
10-
iterable: (_opts: any) =>
11-
(async function* () {
12-
yield "test output\n"
13-
})(),
14-
kill: mockKill,
15-
})
20+
return (_template: TemplateStringsArray, ...args: any[]) => {
21+
// Create a promise that resolves/rejects based on mockShouldThrow
22+
const resultPromise = new Promise((resolve, reject) => {
23+
// Use setImmediate to allow the iterable to be consumed first
24+
setImmediate(() => {
25+
if (mockShouldThrow) {
26+
reject(new MockExecaError("Command failed", mockExitCode, mockErrorSignal))
27+
} else {
28+
resolve({ exitCode: mockExitCode })
29+
}
30+
})
31+
})
32+
33+
return Object.assign(resultPromise, {
34+
pid: mockPid,
35+
iterable: (_opts: any) =>
36+
(async function* () {
37+
yield "test output\n"
38+
})(),
39+
kill: mockKill,
40+
})
41+
}
1642
})
17-
return { execa, ExecaError: class extends Error {} }
43+
return { execa, ExecaError: MockExecaError }
1844
})
1945

2046
vitest.mock("ps-tree", () => ({
@@ -31,6 +57,11 @@ describe("ExecaTerminalProcess", () => {
3157
let originalEnv: NodeJS.ProcessEnv
3258

3359
beforeEach(() => {
60+
// Reset mock state
61+
mockExitCode = 0
62+
mockShouldThrow = false
63+
mockErrorSignal = undefined
64+
3465
originalEnv = { ...process.env }
3566
mockTerminal = {
3667
provider: "execa",
@@ -163,4 +194,40 @@ describe("ExecaTerminalProcess", () => {
163194
expect(terminalProcess["lastRetrievedIndex"]).toBe(0)
164195
})
165196
})
197+
198+
describe("exit code handling", () => {
199+
it("should emit shell_execution_complete with non-zero exit code when command fails", async () => {
200+
mockExitCode = 1
201+
const spy = vitest.fn()
202+
terminalProcess.on("shell_execution_complete", spy)
203+
await terminalProcess.run("exit 1")
204+
expect(spy).toHaveBeenCalledWith({ exitCode: 1 })
205+
})
206+
207+
it("should emit shell_execution_complete with specific non-zero exit code", async () => {
208+
mockExitCode = 127
209+
const spy = vitest.fn()
210+
terminalProcess.on("shell_execution_complete", spy)
211+
await terminalProcess.run("nonexistent_command")
212+
expect(spy).toHaveBeenCalledWith({ exitCode: 127 })
213+
})
214+
215+
it("should handle ExecaError thrown during await with exit code and signal", async () => {
216+
mockShouldThrow = true
217+
mockExitCode = 128
218+
mockErrorSignal = "SIGKILL"
219+
const spy = vitest.fn()
220+
terminalProcess.on("shell_execution_complete", spy)
221+
await terminalProcess.run("killed_command")
222+
expect(spy).toHaveBeenCalledWith({ exitCode: 128, signalName: "SIGKILL" })
223+
})
224+
225+
it("should emit exitCode 0 when command succeeds", async () => {
226+
mockExitCode = 0
227+
const spy = vitest.fn()
228+
terminalProcess.on("shell_execution_complete", spy)
229+
await terminalProcess.run("echo success")
230+
expect(spy).toHaveBeenCalledWith({ exitCode: 0 })
231+
})
232+
})
166233
})

0 commit comments

Comments
 (0)