diff --git a/src/integrations/terminal/ExecaTerminalProcess.ts b/src/integrations/terminal/ExecaTerminalProcess.ts index 7764ecadbe..2b9b97d10a 100644 --- a/src/integrations/terminal/ExecaTerminalProcess.ts +++ b/src/integrations/terminal/ExecaTerminalProcess.ts @@ -40,6 +40,12 @@ export class ExecaTerminalProcess extends BaseTerminalProcess { shell: true, cwd: this.terminal.getCurrentWorkingDirectory(), all: true, + env: { + ...process.env, + // Ensure UTF-8 encoding for Ruby, CocoaPods, etc. + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }, })`${command}` this.pid = subprocess.pid diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts new file mode 100644 index 0000000000..fd30b610af --- /dev/null +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -0,0 +1,123 @@ +// npx vitest run integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +import { vitest, describe, it, expect, beforeEach, afterEach } from "vitest" + +const mockPid = 12345 + +vitest.mock("execa", () => { + const mockKill = vitest.fn() + 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 { execa, ExecaError: class extends Error {} } +}) + +vitest.mock("ps-tree", () => ({ + default: vitest.fn((_: number, cb: any) => cb(null, [])), +})) + +import { execa } from "execa" +import { ExecaTerminalProcess } from "../ExecaTerminalProcess" +import type { RooTerminal } from "../types" + +describe("ExecaTerminalProcess", () => { + let mockTerminal: RooTerminal + let terminalProcess: ExecaTerminalProcess + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + originalEnv = { ...process.env } + mockTerminal = { + provider: "execa", + id: 1, + busy: false, + running: false, + getCurrentWorkingDirectory: vitest.fn().mockReturnValue("/test/cwd"), + isClosed: vitest.fn().mockReturnValue(false), + runCommand: vitest.fn(), + setActiveStream: vitest.fn(), + shellExecutionComplete: vitest.fn(), + getProcessesWithOutput: vitest.fn().mockReturnValue([]), + getUnretrievedOutput: vitest.fn().mockReturnValue(""), + getLastCommand: vitest.fn().mockReturnValue(""), + cleanCompletedProcessQueue: vitest.fn(), + } as unknown as RooTerminal + terminalProcess = new ExecaTerminalProcess(mockTerminal) + }) + + afterEach(() => { + process.env = originalEnv + vitest.clearAllMocks() + }) + + describe("UTF-8 encoding fix", () => { + it("should set LANG and LC_ALL to en_US.UTF-8", async () => { + await terminalProcess.run("echo test") + const execaMock = vitest.mocked(execa) + expect(execaMock).toHaveBeenCalledWith( + expect.objectContaining({ + shell: true, + cwd: "/test/cwd", + all: true, + env: expect.objectContaining({ + LANG: "en_US.UTF-8", + LC_ALL: "en_US.UTF-8", + }), + }), + ) + }) + + it("should preserve existing environment variables", async () => { + process.env.EXISTING_VAR = "existing" + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as any + expect(calledOptions.env.EXISTING_VAR).toBe("existing") + }) + + it("should override existing LANG and LC_ALL values", async () => { + process.env.LANG = "C" + process.env.LC_ALL = "POSIX" + terminalProcess = new ExecaTerminalProcess(mockTerminal) + await terminalProcess.run("echo test") + const execaMock = vitest.mocked(execa) + const calledOptions = execaMock.mock.calls[0][0] as any + expect(calledOptions.env.LANG).toBe("en_US.UTF-8") + expect(calledOptions.env.LC_ALL).toBe("en_US.UTF-8") + }) + }) + + describe("basic functionality", () => { + it("should create instance with terminal reference", () => { + expect(terminalProcess).toBeInstanceOf(ExecaTerminalProcess) + expect(terminalProcess.terminal).toBe(mockTerminal) + }) + + it("should emit shell_execution_complete with exitCode 0", async () => { + const spy = vitest.fn() + terminalProcess.on("shell_execution_complete", spy) + await terminalProcess.run("echo test") + expect(spy).toHaveBeenCalledWith({ exitCode: 0 }) + }) + + it("should emit completed event with full output", async () => { + const spy = vitest.fn() + terminalProcess.on("completed", spy) + await terminalProcess.run("echo test") + expect(spy).toHaveBeenCalledWith("test output\n") + }) + + it("should set and clear active stream", async () => { + await terminalProcess.run("echo test") + expect(mockTerminal.setActiveStream).toHaveBeenCalledWith(expect.any(Object), mockPid) + expect(mockTerminal.setActiveStream).toHaveBeenLastCalledWith(undefined) + }) + }) +})