From 6dfcb4daa98c2197fb89c66cf3330e59e7ef5d12 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Apr 2026 20:27:44 +0700 Subject: [PATCH] fix: Windows test compatibility for worktree, snapshot, and history paths - Replace `mkdir -p` shell calls with `mkdirSync({ recursive: true })` in worktree tests (cmd.exe doesn't support -p) - Normalize path assertions in provider-snapshot-manager tests for Windows drive-letter resolved paths - Add `:` to Claude history path sanitization so Windows drive letters (C:\) don't produce invalid directory names --- .../agent/provider-snapshot-manager.test.ts | 91 ++++++++++--------- .../server/agent/providers/claude-agent.ts | 7 +- ...ude-agent.voice-history-regression.test.ts | 6 +- packages/server/src/utils/worktree.test.ts | 22 +++-- 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 111637d19..43d0cd095 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; @@ -44,6 +45,10 @@ const TEST_CAPABILITIES = { } as const; describe("ProviderSnapshotManager", () => { + const projectCwd = resolve("/tmp/project"); + const projectACwd = resolve("/tmp/project-a"); + const projectBCwd = resolve("/tmp/project-b"); + test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => { const codexModels = deferred(); const claudeModels = deferred(); @@ -59,7 +64,7 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]); expect(getProviderEntry(snapshot, "claude")).toMatchObject({ @@ -102,14 +107,14 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); }); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(getProviderEntry(snapshot, "codex")).toMatchObject({ provider: "codex", status: "ready", @@ -142,10 +147,10 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "unavailable", @@ -173,10 +178,10 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "error", @@ -215,7 +220,7 @@ describe("ProviderSnapshotManager", () => { }; manager.on("change", listener); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); claudeModels.resolve([createModel("claude", "sonnet")]); claudeModes.resolve([createMode("default")]); @@ -224,7 +229,7 @@ describe("ProviderSnapshotManager", () => { expect(changes).toHaveLength(1); }); - expect(changes[0]?.cwd).toBe("/tmp/project"); + expect(changes[0]?.cwd).toBe(projectCwd); expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready"); expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); @@ -256,16 +261,16 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( "gpt-5.1", ); }); - manager.refresh("/tmp/project"); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + manager.refresh(projectCwd); + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "loading", @@ -276,7 +281,7 @@ describe("ProviderSnapshotManager", () => { ]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe( + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.models?.[0]?.id).toBe( "gpt-5.2", ); }); @@ -300,9 +305,9 @@ describe("ProviderSnapshotManager", () => { const changes: ProviderSnapshotEntry[][] = []; manager.on("change", (entries) => changes.push(entries)); - manager.refresh("/tmp/project"); + manager.refresh(projectCwd); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "codex", status: "loading", @@ -312,9 +317,9 @@ describe("ProviderSnapshotManager", () => { }, ]); - manager.refresh("/tmp/project"); - manager.refresh("/tmp/project"); - manager.refresh("/tmp/project"); + manager.refresh(projectCwd); + manager.refresh(projectCwd); + manager.refresh(projectCwd); expect(changes).toHaveLength(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -323,7 +328,7 @@ describe("ProviderSnapshotManager", () => { fetchModes.resolve([createMode("auto")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({ provider: "codex", status: "ready", models: [createModel("codex", "gpt-5.2")], @@ -351,9 +356,9 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); - manager.getSnapshot("/tmp/project"); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); + manager.getSnapshot(projectCwd); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); @@ -365,7 +370,7 @@ describe("ProviderSnapshotManager", () => { codexModels.resolve([createModel("codex", "gpt-5.2")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready"); }); manager.destroy(); @@ -384,25 +389,21 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project-a"); - manager.getSnapshot("/tmp/project-b"); + manager.getSnapshot(projectACwd); + manager.getSnapshot(projectBCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe( - "ready", - ); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe( - "ready", - ); + expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready"); }); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( - "model:/tmp/project-a", + expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe( + `model:${projectACwd}`, ); - expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( - "model:/tmp/project-b", + expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe( + `model:${projectBCwd}`, ); - expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); + expect(seenCwds).toEqual([projectACwd, projectBCwd]); manager.destroy(); }); @@ -420,13 +421,13 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - manager.getSnapshot("/tmp/project"); + manager.getSnapshot(projectCwd); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")?.status).toBe("ready"); + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")?.status).toBe("ready"); }); - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ provider: "zai", status: "ready", label: "ZAI", @@ -441,7 +442,7 @@ describe("ProviderSnapshotManager", () => { const { registry } = createRegistry([createMockProvider({ provider: "claude" })]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - const snapshot = manager.getSnapshot("/tmp/project"); + const snapshot = manager.getSnapshot(projectCwd); expect(snapshot.map((entry) => entry.provider)).toEqual(["claude"]); expect(getProviderEntry(snapshot, "zai")).toBeUndefined(); @@ -464,7 +465,7 @@ describe("ProviderSnapshotManager", () => { ]); const manager = new ProviderSnapshotManager(registry, createTestLogger()); - expect(manager.getSnapshot("/tmp/project")).toEqual([ + expect(manager.getSnapshot(projectCwd)).toEqual([ { provider: "zai", status: "loading", @@ -478,7 +479,7 @@ describe("ProviderSnapshotManager", () => { modes.resolve([createMode("plan")]); await vi.waitFor(() => { - expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ + expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({ provider: "zai", status: "ready", label: "ZAI", diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index cf0f75c59..9ce93275a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -241,6 +241,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau return value === "low" || value === "medium" || value === "high" || value === "max"; } +function sanitizeClaudeProjectPath(cwd: string): string { + return cwd.replace(/[\\/._:]/g, "-"); +} + type ClaudeOptionsLogSummary = { cwd: string | null; permissionMode: string | null; @@ -3260,8 +3264,7 @@ class ClaudeAgentSession implements AgentSession { private resolveHistoryPath(sessionId: string): string | null { const cwd = this.config.cwd; if (!cwd) return null; - // Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes - const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const sanitized = sanitizeClaudeProjectPath(cwd); const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const dir = path.join(configDir, "projects", sanitized); return path.join(dir, `${sessionId}.jsonl`); diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 2d2eb583c..e10f1af97 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -21,6 +21,10 @@ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +function sanitizeClaudeProjectPath(cwd: string): string { + return cwd.replace(/[\\/._:]/g, "-"); +} + function buildSdkQueryMock() { const events = [ { @@ -106,7 +110,7 @@ describe("ClaudeAgentSession history replay regression", () => { configDir = path.join(tempRoot, "claude-config"); mkdirSync(cwd, { recursive: true }); - const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); + const sanitized = sanitizeClaudeProjectPath(cwd); const historyDir = path.join(configDir, "projects", sanitized); mkdirSync(historyDir, { recursive: true }); const historyPath = path.join(historyDir, "history-session.jsonl"); diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 4f36680c3..5b2ff42c5 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -13,7 +13,15 @@ import { } from "./worktree"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { execSync } from "child_process"; -import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + existsSync, + realpathSync, + writeFileSync, + readFileSync, +} from "fs"; import { dirname, join } from "path"; import { tmpdir } from "os"; import net from "node:net"; @@ -30,7 +38,7 @@ describe("createWorktree", () => { paseoHome = join(tempDir, "paseo-home"); // Create a git repo with an initial commit - execSync(`mkdir -p ${repoDir}`); + mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.name 'Test'", { cwd: repoDir }); @@ -68,7 +76,7 @@ describe("createWorktree", () => { const privateTempDir = realpathSync(varTempDir); const varRepoDir = join(varTempDir, "test-repo"); const varPaseoHome = join(varTempDir, "paseo-home"); - execSync(`mkdir -p ${varRepoDir}`); + mkdirSync(varRepoDir, { recursive: true }); execSync("git init -b main", { cwd: varRepoDir }); execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); execSync("git config user.name 'Test'", { cwd: varRepoDir }); @@ -118,7 +126,7 @@ describe("createWorktree", () => { it("treats non-git directories as non-worktrees without throwing", async () => { const nonGitDir = join(tempDir, "not-a-repo"); - execSync(`mkdir -p ${nonGitDir}`); + mkdirSync(nonGitDir, { recursive: true }); const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); @@ -494,7 +502,7 @@ describe("paseo worktree manager", () => { repoDir = join(tempDir, "test-repo"); paseoHome = join(tempDir, "paseo-home"); - execSync(`mkdir -p ${repoDir}`); + mkdirSync(repoDir, { recursive: true }); execSync("git init -b main", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.name 'Test'", { cwd: repoDir }); @@ -512,7 +520,7 @@ describe("paseo worktree manager", () => { const repoB = join(tempDir, "team-b", "test-repo"); for (const repo of [repoA, repoB]) { - execSync(`mkdir -p ${repo}`); + mkdirSync(repo, { recursive: true }); execSync("git init -b main", { cwd: repo }); execSync("git config user.email 'test@test.com'", { cwd: repo }); execSync("git config user.name 'Test'", { cwd: repo }); @@ -584,7 +592,7 @@ describe("paseo worktree manager", () => { }); const nestedDir = join(created.worktreePath, "nested", "dir"); - execSync(`mkdir -p ${nestedDir}`); + mkdirSync(nestedDir, { recursive: true }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome }); expect(existsSync(created.worktreePath)).toBe(false);