Files
paseo/packages/server/src/utils/worktree-shell-selection.test.ts
T
Mohamed BoudraandGitHub 08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00

183 lines
5.2 KiB
TypeScript

import { type ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const execFileMock = vi.hoisted(() => vi.fn());
const spawnProcessMock = vi.hoisted(() => vi.fn());
vi.mock("child_process", async () => {
const actual = await vi.importActual<typeof import("child_process")>("child_process");
return {
...actual,
execFile: execFileMock,
};
});
vi.mock("./spawn.js", async () => {
const actual = await vi.importActual<typeof import("./spawn.js")>("./spawn.js");
return {
...actual,
spawnProcess: spawnProcessMock,
};
});
function emitSuccessfulClose(child: ChildProcess): void {
child.emit("close", 0);
}
function createSpawnChildStub(): ChildProcess {
const child = new EventEmitter() as ChildProcess;
Object.assign(child, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
});
queueMicrotask(() => emitSuccessfulClose(child));
return child;
}
describe("worktree shell selection", () => {
const originalPlatform = process.platform;
beforeEach(() => {
execFileMock.mockReset();
spawnProcessMock.mockReset();
execFileMock.mockImplementation(
(
_file: string,
_args: string[],
_options: unknown,
callback?: (error: Error | null, stdout: string, stderr: string) => void,
) => {
callback?.(null, "", "");
return {};
},
);
spawnProcessMock.mockImplementation(createSpawnChildStub);
});
afterEach(() => {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
vi.resetModules();
});
it("routes teardown command execution through powershell on win32", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
const originalBashEnv = process.env.BASH_ENV;
process.env.BASH_ENV = "should-not-leak";
try {
mkdirSync(join(worktreePath, ".git"), { recursive: true });
writeFileSync(
join(worktreePath, "paseo.json"),
JSON.stringify({
worktree: {
teardown: ["Write-Output 'teardown'"],
},
}),
"utf8",
);
const { runWorktreeTeardownCommands } = await import("./worktree.js");
await runWorktreeTeardownCommands({
worktreePath,
repoRootPath: worktreePath,
branchName: "main",
});
expect(execFileMock).toHaveBeenCalledTimes(1);
expect(execFileMock).toHaveBeenCalledWith(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Write-Output 'teardown'",
],
expect.objectContaining({ cwd: worktreePath }),
expect.any(Function),
);
const execOptions = execFileMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
expect(execOptions.env?.BASH_ENV).toBeUndefined();
} finally {
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
rmSync(worktreePath, { recursive: true, force: true });
}
});
it("routes streamed setup command execution through powershell on win32", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
const worktreePath = mkdtempSync(join(tmpdir(), "worktree-shell-selection-"));
const originalBashEnv = process.env.BASH_ENV;
process.env.BASH_ENV = "should-not-leak";
try {
writeFileSync(
join(worktreePath, "paseo.json"),
JSON.stringify({
worktree: {
setup: ["Write-Output 'setup'"],
},
}),
"utf8",
);
const { runWorktreeSetupCommands } = await import("./worktree.js");
await runWorktreeSetupCommands({
worktreePath,
branchName: "main",
cleanupOnFailure: false,
runtimeEnv: {
PASEO_SOURCE_CHECKOUT_PATH: worktreePath,
PASEO_ROOT_PATH: worktreePath,
PASEO_WORKTREE_PATH: worktreePath,
PASEO_BRANCH_NAME: "main",
PASEO_WORKTREE_PORT: "12345",
},
onEvent: () => {},
});
expect(spawnProcessMock).toHaveBeenCalledTimes(1);
expect(spawnProcessMock).toHaveBeenCalledWith(
"powershell",
[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
"Write-Output 'setup'",
],
expect.objectContaining({ cwd: worktreePath, shell: false }),
);
const spawnOptions = spawnProcessMock.mock.calls[0]?.[2] as { env?: NodeJS.ProcessEnv };
expect(spawnOptions.env?.BASH_ENV).toBeUndefined();
} finally {
if (originalBashEnv === undefined) {
delete process.env.BASH_ENV;
} else {
process.env.BASH_ENV = originalBashEnv;
}
rmSync(worktreePath, { recursive: true, force: true });
}
});
});