|
1 | 1 | // Verifies shell selection, PATH lookup, and platform-specific shell helpers. |
| 2 | +import { spawnSync } from "node:child_process"; |
2 | 3 | import fs from "node:fs"; |
3 | 4 | import os from "node:os"; |
4 | 5 | import path from "node:path"; |
|
8 | 9 | buildShellCommandInvocation, |
9 | 10 | detectRuntimeShell, |
10 | 11 | getBashShellConfig, |
11 | | - getShellEnv, |
| 12 | + getBashShellEnv, |
12 | 13 | getShellConfig, |
13 | 14 | sanitizeBinaryOutput, |
14 | 15 | } from "./shell-utils.js"; |
@@ -195,7 +196,7 @@ describe("getBashShellConfig", () => { |
195 | 196 | let envSnapshot: ReturnType<typeof captureEnv>; |
196 | 197 |
|
197 | 198 | beforeEach(() => { |
198 | | - envSnapshot = captureEnv(["ProgramFiles", "ProgramFiles(x86)", "PATH"]); |
| 199 | + envSnapshot = captureEnv(["ProgramFiles", "ProgramFiles(x86)", "PATH", "Path"]); |
199 | 200 | vi.spyOn(process, "platform", "get").mockReturnValue("win32"); |
200 | 201 | }); |
201 | 202 |
|
@@ -225,6 +226,53 @@ describe("getBashShellConfig", () => { |
225 | 226 | }); |
226 | 227 | }); |
227 | 228 |
|
| 229 | + it("prepends coreutils for a standard Git for Windows install", () => { |
| 230 | + const programFiles = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-git-bash-env-")); |
| 231 | + tempDirs.push(programFiles); |
| 232 | + const gitRoot = path.join(programFiles, "Git"); |
| 233 | + const bashPath = path.join(gitRoot, "bin", "bash.exe"); |
| 234 | + const usrBin = path.join(gitRoot, "usr", "bin"); |
| 235 | + fs.mkdirSync(path.dirname(bashPath), { recursive: true }); |
| 236 | + fs.mkdirSync(path.join(gitRoot, "cmd"), { recursive: true }); |
| 237 | + fs.mkdirSync(usrBin, { recursive: true }); |
| 238 | + fs.writeFileSync(bashPath, ""); |
| 239 | + fs.writeFileSync(path.join(gitRoot, "cmd", "git.exe"), ""); |
| 240 | + process.env.PATH = path.join(programFiles, "OtherBin"); |
| 241 | + |
| 242 | + const env = getBashShellEnv(bashPath); |
| 243 | + |
| 244 | + expect(env.PATH?.split(path.delimiter)[0]).toBe(usrBin); |
| 245 | + expect(env.PATH).toContain(process.env.PATH); |
| 246 | + expect(Object.keys(env).filter((key) => key.toLowerCase() === "path")).toEqual(["PATH"]); |
| 247 | + }); |
| 248 | + |
| 249 | + it("recognizes portable Git for Windows installs", () => { |
| 250 | + const gitRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-portable-git-")); |
| 251 | + tempDirs.push(gitRoot); |
| 252 | + const bashPath = path.join(gitRoot, "usr", "bin", "bash.exe"); |
| 253 | + const usrBin = path.dirname(bashPath); |
| 254 | + fs.mkdirSync(usrBin, { recursive: true }); |
| 255 | + fs.mkdirSync(path.join(gitRoot, "cmd"), { recursive: true }); |
| 256 | + fs.writeFileSync(bashPath, ""); |
| 257 | + fs.writeFileSync(path.join(gitRoot, "cmd", "git.exe"), ""); |
| 258 | + |
| 259 | + expect(getBashShellEnv(bashPath).PATH?.split(path.delimiter)[0]).toBe(usrBin); |
| 260 | + }); |
| 261 | + |
| 262 | + it("leaves unrelated MSYS2 installs unchanged", () => { |
| 263 | + const msysRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-msys2-")); |
| 264 | + tempDirs.push(msysRoot); |
| 265 | + const bashPath = path.join(msysRoot, "usr", "bin", "bash.exe"); |
| 266 | + fs.mkdirSync(path.dirname(bashPath), { recursive: true }); |
| 267 | + fs.writeFileSync(bashPath, ""); |
| 268 | + process.env.PATH = path.join(msysRoot, "ucrt64", "bin"); |
| 269 | + |
| 270 | + const env = getBashShellEnv(bashPath); |
| 271 | + |
| 272 | + expect(env.PATH?.split(path.delimiter)[0]).not.toBe(path.dirname(bashPath)); |
| 273 | + expect(env.PATH).toContain(process.env.PATH); |
| 274 | + }); |
| 275 | + |
228 | 276 | it.each(["System32", "Sysnative"])( |
229 | 277 | "uses stdin transport for the legacy %s WSL launcher", |
230 | 278 | (systemDirectory) => { |
@@ -267,24 +315,49 @@ describe("getBashShellConfig", () => { |
267 | 315 | }); |
268 | 316 | }); |
269 | 317 |
|
270 | | -describe("getShellEnv", () => { |
| 318 | +describe("getBashShellEnv", () => { |
271 | 319 | let envSnapshot: ReturnType<typeof captureEnv>; |
272 | 320 |
|
273 | 321 | beforeEach(() => { |
274 | | - envSnapshot = captureEnv(["PATH"]); |
| 322 | + envSnapshot = captureEnv(["PATH", "Path"]); |
275 | 323 | }); |
276 | 324 |
|
277 | 325 | afterEach(() => { |
| 326 | + vi.restoreAllMocks(); |
278 | 327 | envSnapshot.restore(); |
279 | 328 | }); |
280 | 329 |
|
281 | 330 | it("returns an env object with the OpenClaw bin dir on PATH", () => { |
282 | 331 | process.env.PATH = "/usr/bin"; |
283 | | - const env = getShellEnv(); |
| 332 | + const env = getBashShellEnv(); |
284 | 333 |
|
285 | 334 | expect(env.PATH).toContain("/usr/bin"); |
286 | 335 | expect(env.PATH).toContain(".openclaw"); |
287 | 336 | }); |
| 337 | + |
| 338 | + it("collapses case-insensitive PATH duplicates before Windows spawn", () => { |
| 339 | + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); |
| 340 | + |
| 341 | + const env = getBashShellEnv(undefined, { PATH: "/selected", Path: "/discarded" }); |
| 342 | + |
| 343 | + expect(Object.keys(env).filter((key) => key.toLowerCase() === "path")).toEqual(["PATH"]); |
| 344 | + expect(env.PATH).toContain("/selected"); |
| 345 | + expect(env.PATH).not.toContain("/discarded"); |
| 346 | + }); |
| 347 | + |
| 348 | + it.runIf(isWin)("passes one canonical PATH entry to a child process", () => { |
| 349 | + const result = spawnSync( |
| 350 | + process.execPath, |
| 351 | + [ |
| 352 | + "-e", |
| 353 | + "process.stdout.write(JSON.stringify(Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')))", |
| 354 | + ], |
| 355 | + { encoding: "utf8", env: getBashShellEnv() }, |
| 356 | + ); |
| 357 | + |
| 358 | + expect(result.status).toBe(0); |
| 359 | + expect(JSON.parse(result.stdout)).toEqual(["PATH"]); |
| 360 | + }); |
288 | 361 | }); |
289 | 362 |
|
290 | 363 | describe("detectRuntimeShell", () => { |
|
0 commit comments