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
This commit is contained in:
Mohamed Boudra
2026-04-13 20:27:44 +07:00
parent 95e112c185
commit 6dfcb4daa9
4 changed files with 71 additions and 55 deletions
@@ -1,3 +1,4 @@
import { resolve } from "node:path";
import { describe, expect, test, vi } from "vitest"; import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
@@ -44,6 +45,10 @@ const TEST_CAPABILITIES = {
} as const; } as const;
describe("ProviderSnapshotManager", () => { 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 () => { test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => {
const codexModels = deferred<AgentModelDefinition[]>(); const codexModels = deferred<AgentModelDefinition[]>();
const claudeModels = deferred<AgentModelDefinition[]>(); const claudeModels = deferred<AgentModelDefinition[]>();
@@ -59,7 +64,7 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); 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(snapshot.map((entry) => entry.provider)).toEqual(["codex", "claude"]);
expect(getProviderEntry(snapshot, "claude")).toMatchObject({ expect(getProviderEntry(snapshot, "claude")).toMatchObject({
@@ -102,14 +107,14 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready"); expect(getProviderEntry(manager.getSnapshot(projectCwd), "claude")?.status).toBe("ready");
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.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({ expect(getProviderEntry(snapshot, "codex")).toMatchObject({
provider: "codex", provider: "codex",
status: "ready", status: "ready",
@@ -142,10 +147,10 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(manager.getSnapshot("/tmp/project")).toEqual([ expect(manager.getSnapshot(projectCwd)).toEqual([
{ {
provider: "codex", provider: "codex",
status: "unavailable", status: "unavailable",
@@ -173,10 +178,10 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(manager.getSnapshot("/tmp/project")).toEqual([ expect(manager.getSnapshot(projectCwd)).toEqual([
{ {
provider: "codex", provider: "codex",
status: "error", status: "error",
@@ -215,7 +220,7 @@ describe("ProviderSnapshotManager", () => {
}; };
manager.on("change", listener); manager.on("change", listener);
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
claudeModels.resolve([createModel("claude", "sonnet")]); claudeModels.resolve([createModel("claude", "sonnet")]);
claudeModes.resolve([createMode("default")]); claudeModes.resolve([createMode("default")]);
@@ -224,7 +229,7 @@ describe("ProviderSnapshotManager", () => {
expect(changes).toHaveLength(1); 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 ?? [], "claude")?.status).toBe("ready");
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading"); expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
@@ -256,16 +261,16 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { 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", "gpt-5.1",
); );
}); });
manager.refresh("/tmp/project"); manager.refresh(projectCwd);
expect(manager.getSnapshot("/tmp/project")).toEqual([ expect(manager.getSnapshot(projectCwd)).toEqual([
{ {
provider: "codex", provider: "codex",
status: "loading", status: "loading",
@@ -276,7 +281,7 @@ describe("ProviderSnapshotManager", () => {
]); ]);
await vi.waitFor(() => { 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", "gpt-5.2",
); );
}); });
@@ -300,9 +305,9 @@ describe("ProviderSnapshotManager", () => {
const changes: ProviderSnapshotEntry[][] = []; const changes: ProviderSnapshotEntry[][] = [];
manager.on("change", (entries) => changes.push(entries)); 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", provider: "codex",
status: "loading", status: "loading",
@@ -312,9 +317,9 @@ describe("ProviderSnapshotManager", () => {
}, },
]); ]);
manager.refresh("/tmp/project"); manager.refresh(projectCwd);
manager.refresh("/tmp/project"); manager.refresh(projectCwd);
manager.refresh("/tmp/project"); manager.refresh(projectCwd);
expect(changes).toHaveLength(1); expect(changes).toHaveLength(1);
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -323,7 +328,7 @@ describe("ProviderSnapshotManager", () => {
fetchModes.resolve([createMode("auto")]); fetchModes.resolve([createMode("auto")]);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")).toMatchObject({ expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")).toMatchObject({
provider: "codex", provider: "codex",
status: "ready", status: "ready",
models: [createModel("codex", "gpt-5.2")], models: [createModel("codex", "gpt-5.2")],
@@ -351,9 +356,9 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1); expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
@@ -365,7 +370,7 @@ describe("ProviderSnapshotManager", () => {
codexModels.resolve([createModel("codex", "gpt-5.2")]); codexModels.resolve([createModel("codex", "gpt-5.2")]);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready"); expect(getProviderEntry(manager.getSnapshot(projectCwd), "codex")?.status).toBe("ready");
}); });
manager.destroy(); manager.destroy();
@@ -384,25 +389,21 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project-a"); manager.getSnapshot(projectACwd);
manager.getSnapshot("/tmp/project-b"); manager.getSnapshot(projectBCwd);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe( expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.status).toBe("ready");
"ready", expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.status).toBe("ready");
);
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe(
"ready",
);
}); });
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe( expect(getProviderEntry(manager.getSnapshot(projectACwd), "codex")?.models?.[0]?.id).toBe(
"model:/tmp/project-a", `model:${projectACwd}`,
); );
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe( expect(getProviderEntry(manager.getSnapshot(projectBCwd), "codex")?.models?.[0]?.id).toBe(
"model:/tmp/project-b", `model:${projectBCwd}`,
); );
expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]); expect(seenCwds).toEqual([projectACwd, projectBCwd]);
manager.destroy(); manager.destroy();
}); });
@@ -420,13 +421,13 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
manager.getSnapshot("/tmp/project"); manager.getSnapshot(projectCwd);
await vi.waitFor(() => { 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", provider: "zai",
status: "ready", status: "ready",
label: "ZAI", label: "ZAI",
@@ -441,7 +442,7 @@ describe("ProviderSnapshotManager", () => {
const { registry } = createRegistry([createMockProvider({ provider: "claude" })]); const { registry } = createRegistry([createMockProvider({ provider: "claude" })]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); 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(snapshot.map((entry) => entry.provider)).toEqual(["claude"]);
expect(getProviderEntry(snapshot, "zai")).toBeUndefined(); expect(getProviderEntry(snapshot, "zai")).toBeUndefined();
@@ -464,7 +465,7 @@ describe("ProviderSnapshotManager", () => {
]); ]);
const manager = new ProviderSnapshotManager(registry, createTestLogger()); const manager = new ProviderSnapshotManager(registry, createTestLogger());
expect(manager.getSnapshot("/tmp/project")).toEqual([ expect(manager.getSnapshot(projectCwd)).toEqual([
{ {
provider: "zai", provider: "zai",
status: "loading", status: "loading",
@@ -478,7 +479,7 @@ describe("ProviderSnapshotManager", () => {
modes.resolve([createMode("plan")]); modes.resolve([createMode("plan")]);
await vi.waitFor(() => { await vi.waitFor(() => {
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "zai")).toMatchObject({ expect(getProviderEntry(manager.getSnapshot(projectCwd), "zai")).toMatchObject({
provider: "zai", provider: "zai",
status: "ready", status: "ready",
label: "ZAI", label: "ZAI",
@@ -241,6 +241,10 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
return value === "low" || value === "medium" || value === "high" || value === "max"; return value === "low" || value === "medium" || value === "high" || value === "max";
} }
function sanitizeClaudeProjectPath(cwd: string): string {
return cwd.replace(/[\\/._:]/g, "-");
}
type ClaudeOptionsLogSummary = { type ClaudeOptionsLogSummary = {
cwd: string | null; cwd: string | null;
permissionMode: string | null; permissionMode: string | null;
@@ -3260,8 +3264,7 @@ class ClaudeAgentSession implements AgentSession {
private resolveHistoryPath(sessionId: string): string | null { private resolveHistoryPath(sessionId: string): string | null {
const cwd = this.config.cwd; const cwd = this.config.cwd;
if (!cwd) return null; if (!cwd) return null;
// Match Claude CLI's path sanitization: replace slashes, dots, and underscores with dashes const sanitized = sanitizeClaudeProjectPath(cwd);
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-");
const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
const dir = path.join(configDir, "projects", sanitized); const dir = path.join(configDir, "projects", sanitized);
return path.join(dir, `${sessionId}.jsonl`); return path.join(dir, `${sessionId}.jsonl`);
@@ -21,6 +21,10 @@ const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
function sanitizeClaudeProjectPath(cwd: string): string {
return cwd.replace(/[\\/._:]/g, "-");
}
function buildSdkQueryMock() { function buildSdkQueryMock() {
const events = [ const events = [
{ {
@@ -106,7 +110,7 @@ describe("ClaudeAgentSession history replay regression", () => {
configDir = path.join(tempRoot, "claude-config"); configDir = path.join(tempRoot, "claude-config");
mkdirSync(cwd, { recursive: true }); mkdirSync(cwd, { recursive: true });
const sanitized = cwd.replace(/[\\/\.]/g, "-").replace(/_/g, "-"); const sanitized = sanitizeClaudeProjectPath(cwd);
const historyDir = path.join(configDir, "projects", sanitized); const historyDir = path.join(configDir, "projects", sanitized);
mkdirSync(historyDir, { recursive: true }); mkdirSync(historyDir, { recursive: true });
const historyPath = path.join(historyDir, "history-session.jsonl"); const historyPath = path.join(historyDir, "history-session.jsonl");
+15 -7
View File
@@ -13,7 +13,15 @@ import {
} from "./worktree"; } from "./worktree";
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js"; import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
import { execSync } from "child_process"; 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 { dirname, join } from "path";
import { tmpdir } from "os"; import { tmpdir } from "os";
import net from "node:net"; import net from "node:net";
@@ -30,7 +38,7 @@ describe("createWorktree", () => {
paseoHome = join(tempDir, "paseo-home"); paseoHome = join(tempDir, "paseo-home");
// Create a git repo with an initial commit // 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 init -b main", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir }); execSync("git config user.name 'Test'", { cwd: repoDir });
@@ -68,7 +76,7 @@ describe("createWorktree", () => {
const privateTempDir = realpathSync(varTempDir); const privateTempDir = realpathSync(varTempDir);
const varRepoDir = join(varTempDir, "test-repo"); const varRepoDir = join(varTempDir, "test-repo");
const varPaseoHome = join(varTempDir, "paseo-home"); const varPaseoHome = join(varTempDir, "paseo-home");
execSync(`mkdir -p ${varRepoDir}`); mkdirSync(varRepoDir, { recursive: true });
execSync("git init -b main", { cwd: varRepoDir }); execSync("git init -b main", { cwd: varRepoDir });
execSync("git config user.email 'test@test.com'", { cwd: varRepoDir }); execSync("git config user.email 'test@test.com'", { cwd: varRepoDir });
execSync("git config user.name 'Test'", { 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 () => { it("treats non-git directories as non-worktrees without throwing", async () => {
const nonGitDir = join(tempDir, "not-a-repo"); const nonGitDir = join(tempDir, "not-a-repo");
execSync(`mkdir -p ${nonGitDir}`); mkdirSync(nonGitDir, { recursive: true });
const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome }); const ownership = await isPaseoOwnedWorktreeCwd(nonGitDir, { paseoHome });
@@ -494,7 +502,7 @@ describe("paseo worktree manager", () => {
repoDir = join(tempDir, "test-repo"); repoDir = join(tempDir, "test-repo");
paseoHome = join(tempDir, "paseo-home"); paseoHome = join(tempDir, "paseo-home");
execSync(`mkdir -p ${repoDir}`); mkdirSync(repoDir, { recursive: true });
execSync("git init -b main", { cwd: repoDir }); execSync("git init -b main", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir }); execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { 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"); const repoB = join(tempDir, "team-b", "test-repo");
for (const repo of [repoA, repoB]) { for (const repo of [repoA, repoB]) {
execSync(`mkdir -p ${repo}`); mkdirSync(repo, { recursive: true });
execSync("git init -b main", { cwd: repo }); execSync("git init -b main", { cwd: repo });
execSync("git config user.email 'test@test.com'", { cwd: repo }); execSync("git config user.email 'test@test.com'", { cwd: repo });
execSync("git config user.name 'Test'", { 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"); const nestedDir = join(created.worktreePath, "nested", "dir");
execSync(`mkdir -p ${nestedDir}`); mkdirSync(nestedDir, { recursive: true });
await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome }); await deletePaseoWorktree({ cwd: repoDir, worktreePath: nestedDir, paseoHome });
expect(existsSync(created.worktreePath)).toBe(false); expect(existsSync(created.worktreePath)).toBe(false);