Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/integrations/terminal/ExecaTerminalProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import process from "process"
import type { RooTerminal } from "./types"
import { BaseTerminal } from "./BaseTerminal"
import { BaseTerminalProcess } from "./BaseTerminalProcess"
import { getShell } from "../../utils/shell"

export class ExecaTerminalProcess extends BaseTerminalProcess {
private terminalRef: WeakRef<RooTerminal>
Expand Down Expand Up @@ -40,7 +41,7 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
this.isHot = true

this.subprocess = execa({
shell: BaseTerminal.getExecaShellPath() || true,
shell: BaseTerminal.getExecaShellPath() ?? getShell(),
Comment thread
xcloudx01 marked this conversation as resolved.
Outdated
cwd: this.terminal.getCurrentWorkingDirectory(),
all: true,
// Ignore stdin to ensure non-interactive mode and prevent hanging
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vitest.mock("ps-tree", () => ({

import { execa } from "execa"
import { ExecaTerminalProcess } from "../ExecaTerminalProcess"
import * as shellUtils from "../../../utils/shell"
import { BaseTerminal } from "../BaseTerminal"
import type { RooTerminal } from "../types"

Expand Down Expand Up @@ -63,11 +64,13 @@ describe("ExecaTerminalProcess", () => {

describe("UTF-8 encoding fix", () => {
it("should set LANG and LC_ALL to en_US.UTF-8", async () => {
// Deterministic shell so the assertion focuses solely on LANG/LC_ALL.
vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
expect(execaMock).toHaveBeenCalledWith(
expect.objectContaining({
shell: true,
shell: "/bin/zsh",
cwd: "/test/cwd",
all: true,
env: expect.objectContaining({
Expand Down Expand Up @@ -109,15 +112,19 @@ describe("ExecaTerminalProcess", () => {
)
})

it("should fall back to shell=true when execaShellPath is undefined", async () => {
it("when execaShellPath is unset, Execa resolves through getShell() (never shell:true)", async () => {
BaseTerminal.setExecaShellPath(undefined)
const resolved = "/resolved/pwsh.exe"
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue(resolved)
await terminalProcess.run("echo test")
const execaMock = vitest.mocked(execa)
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(execaMock).toHaveBeenCalledWith(
expect.objectContaining({
shell: true,
shell: resolved,
}),
)
expect(execaMock).not.toHaveBeenCalledWith(expect.objectContaining({ shell: true }))
})
})

Expand Down Expand Up @@ -191,4 +198,52 @@ describe("ExecaTerminalProcess", () => {
expect(terminalProcess["lastRetrievedIndex"]).toBe(0)
})
})

describe("cross-path shell invariant (#705 regression)", () => {
// Bridge through unknown: the mock records the raw options object, whose
// declared type under execa's overloads is string|URL, not a plain record.
const capturedShellOption = (): Record<string, string | boolean> =>
vitest.mocked(execa).mock.calls[0][0] as unknown as Record<string, string | boolean>

beforeEach(() => {
BaseTerminal.setExecaShellPath(undefined)
})

it("system-prompt resolved shell == Execa execution shell when no explicit execaShellPath", async () => {
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/bin/zsh")
await terminalProcess.run("echo test")
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(capturedShellOption().shell).toBe("/bin/zsh")
})

it("keeps the Execa shell equal to getShell() when a Zoo profile override is set", async () => {
BaseTerminal.setExecaShellPath(undefined)
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("C:\\Windows\\System32\\pwsh.exe")
await terminalProcess.run("echo test")
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(capturedShellOption().shell).toContain("pwsh.exe")
Comment thread
xcloudx01 marked this conversation as resolved.
Outdated
})

it("uses PowerShell when VS Code resolves PowerShell and execaShellPath is unset", async () => {
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("powershell.exe")
await terminalProcess.run("echo test")
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(capturedShellOption().shell).toBe("powershell.exe")
})

it("preserves a deliberately selected Command Prompt profile when execaShellPath is unset", async () => {
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("cmd.exe")
await terminalProcess.run("echo test")
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(capturedShellOption().shell).toBe("cmd.exe")
})

it("does NOT delegate to shell:true even when getShell() returns an unusual path", async () => {
const getShellSpy = vi.spyOn(shellUtils, "getShell").mockReturnValue("/opt/custom/fish")
await terminalProcess.run("echo test")
expect(getShellSpy).toHaveBeenCalledTimes(1)
expect(capturedShellOption().shell).not.toBe(true)
expect(capturedShellOption().shell).toBe("/opt/custom/fish")
})
})
})
Loading