Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
164 changes: 164 additions & 0 deletions apps/code/src/main/utils/fixPath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mockSpawnSync = vi.hoisted(() => vi.fn());
const mockExistsSync = vi.hoisted(() => vi.fn(() => false));
const mockReadFileSync = vi.hoisted(() => vi.fn());
const mockWriteFileSync = vi.hoisted(() => vi.fn());
const mockMkdirSync = vi.hoisted(() => vi.fn());
const mockUserInfo = vi.hoisted(() =>
vi.fn(() => ({ shell: "/bin/zsh" }) as { shell: string | null }),
);
const mockGetUserDataDir = vi.hoisted(() => vi.fn(() => "/tmp/posthog-code"));

vi.mock("node:child_process", () => ({
spawnSync: mockSpawnSync,
default: { spawnSync: mockSpawnSync },
}));

vi.mock("node:fs", () => ({
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
mkdirSync: mockMkdirSync,
default: {
existsSync: mockExistsSync,
readFileSync: mockReadFileSync,
writeFileSync: mockWriteFileSync,
mkdirSync: mockMkdirSync,
},
}));

vi.mock("node:os", () => ({
userInfo: mockUserInfo,
default: { userInfo: mockUserInfo },
}));

vi.mock("./env", () => ({
getUserDataDir: mockGetUserDataDir,
}));

const DELIMITER = "_SHELL_ENV_DELIMITER_";

function shellOutput(envPath: string): string {
return `${DELIMITER}\nPATH=${envPath}\n${DELIMITER}`;
}

const ORIGINAL_PLATFORM = process.platform;
const ORIGINAL_PATH = process.env.PATH;

function setPlatform(platform: NodeJS.Platform) {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
}

describe("fixPath", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
setPlatform("darwin");
process.env.PATH = "/usr/bin:/bin";
mockExistsSync.mockReturnValue(false);
});

afterEach(() => {
setPlatform(ORIGINAL_PLATFORM);
process.env.PATH = ORIGINAL_PATH;
});

it("merges fallback paths when shell PATH lacks /opt/homebrew/bin", async () => {
mockSpawnSync.mockReturnValue({
status: 0,
stdout: shellOutput("/usr/local/bin:/usr/bin:/bin"),
});

const { fixPath } = await import("./fixPath");
fixPath();

const parts = process.env.PATH?.split(":") ?? [];
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/opt/homebrew/sbin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
});

it("does not duplicate fallback paths already present in shell PATH", async () => {
mockSpawnSync.mockReturnValue({
status: 0,
stdout: shellOutput("/opt/homebrew/bin:/usr/local/bin:/usr/bin"),
});

const { fixPath } = await import("./fixPath");
fixPath();

const parts = process.env.PATH?.split(":") ?? [];
const homebrewCount = parts.filter((p) => p === "/opt/homebrew/bin").length;
expect(homebrewCount).toBe(1);
const usrLocalCount = parts.filter((p) => p === "/usr/local/bin").length;
expect(usrLocalCount).toBe(1);
});

it("falls back to fallback paths when shell resolution fails entirely", async () => {
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });

const { fixPath } = await import("./fixPath");
fixPath();

const parts = process.env.PATH?.split(":") ?? [];
expect(parts).toContain("/opt/homebrew/bin");
expect(parts).toContain("/opt/homebrew/sbin");
expect(parts).toContain("/usr/local/bin");
expect(parts).toContain("/usr/bin");
});

it("merges fallback paths into a cached PATH that lacks them", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
path: "/usr/local/bin:/usr/bin:/bin",
timestamp: Date.now(),
}),
);

const { fixPath } = await import("./fixPath");
fixPath();

const parts = process.env.PATH?.split(":") ?? [];
expect(parts).toContain("/opt/homebrew/bin");
expect(mockSpawnSync).not.toHaveBeenCalled();
});

it("ignores stale cache and re-resolves via shell", async () => {
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue(
JSON.stringify({
path: "/some/old/path",
timestamp: Date.now() - 2 * 60 * 60 * 1000,
}),
);
mockSpawnSync.mockReturnValue({
status: 0,
stdout: shellOutput("/usr/local/bin:/usr/bin"),
});

const { fixPath } = await import("./fixPath");
fixPath();

expect(mockSpawnSync).toHaveBeenCalled();
const parts = process.env.PATH?.split(":") ?? [];
expect(parts).not.toContain("/some/old/path");
expect(parts).toContain("/opt/homebrew/bin");
});

it("returns early on win32 without touching PATH", async () => {
setPlatform("win32");
process.env.PATH = "C:\\Windows\\System32";

const { fixPath } = await import("./fixPath");
fixPath();

expect(mockSpawnSync).not.toHaveBeenCalled();
expect(process.env.PATH).toBe("C:\\Windows\\System32");
});
});
Comment on lines +69 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Prefer parameterised tests for shared assertion patterns

Tests "merges fallback paths when shell PATH lacks /opt/homebrew/bin" (line 69), "falls back to fallback paths when shell resolution fails entirely" (line 92), and "merges fallback paths into a cached PATH that lacks them" (line 106) all end with the same four toContain assertions for the fallback paths. Per the team's stated preference for parameterised tests, these cases could be collapsed into a single it.each that varies the mock setup (shell success / shell fail / cache hit) while sharing the assertion block, eliminating the duplication.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/main/utils/fixPath.test.ts
Line: 69-164

Comment:
**Prefer parameterised tests for shared assertion patterns**

Tests `"merges fallback paths when shell PATH lacks /opt/homebrew/bin"` (line 69), `"falls back to fallback paths when shell resolution fails entirely"` (line 92), and `"merges fallback paths into a cached PATH that lacks them"` (line 106) all end with the same four `toContain` assertions for the fallback paths. Per the team's stated preference for parameterised tests, these cases could be collapsed into a single `it.each` that varies the mock setup (shell success / shell fail / cache hit) while sharing the assertion block, eliminating the duplication.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

15 changes: 9 additions & 6 deletions apps/code/src/main/utils/fixPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,19 +170,21 @@ function getShellPath(shell: string): string | undefined {
return env?.PATH;
}

function buildFallbackPath(): string {
return [...FALLBACK_PATHS, process.env.PATH].filter(Boolean).join(":");
function mergeFallbackPaths(basePath: string | undefined): string {
const base = basePath ? basePath.split(":").filter(Boolean) : [];
const existing = new Set(base);
const additions = FALLBACK_PATHS.filter((p) => !existing.has(p));
return [...additions, ...base].join(":");
}

export function fixPath(): void {
if (process.platform === "win32") {
return;
}

// Try cached PATH first (instant, no shell spawn)
const cached = readCachedPath();
if (cached) {
process.env.PATH = cached;
process.env.PATH = mergeFallbackPaths(cached);
return;
}

Expand All @@ -191,9 +193,10 @@ export function fixPath(): void {

if (shellPath) {
const cleaned = stripAnsi(shellPath);
process.env.PATH = cleaned;
const merged = mergeFallbackPaths(cleaned);
process.env.PATH = merged;
writeCachedPath(cleaned);
} else {
process.env.PATH = buildFallbackPath();
process.env.PATH = mergeFallbackPaths(process.env.PATH);
}
}
Loading