Fix UTF-8 encoding in ExecaTerminalProcess (#3989)

* Fix UTF-8 encoding issue in integrated terminal

- Set LANG and LC_ALL environment variables to en_US.UTF-8
- Resolves Encoding::CompatibilityError in Ruby/CocoaPods commands
- Ensures consistent UTF-8 encoding across all terminal sessions

This change addresses the terminal encoding issue where commands like
'pod install' would fail due to incompatible character encoding. The
fix ensures all integrated terminals are initialized with proper
UTF-8 locale settings.

* Add comprehensive unit tests for ExecaTerminalProcess UTF-8 encoding fix

- Tests verify LANG and LC_ALL are set to en_US.UTF-8
- Tests ensure existing environment variables are preserved
- Tests confirm UTF-8 settings override conflicting locale values
- Addresses PR feedback requesting test coverage for encoding fix
- All 7 tests passing with proper mocking of execa and ps-tree
This commit is contained in:
Ryan Pfister 2025-05-30 22:24:04 +02:00 committed by GitHub
parent 116a50f7b4
commit 177e7a8eb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 129 additions and 0 deletions

View file

@ -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

View file

@ -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)
})
})
})