import { execFile } from "node:child_process"; import { mkdtemp, rm, realpath } from "node:fs/promises"; import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import worktreePlugin from "@composio/ao-plugin-workspace-worktree"; import type { ProjectConfig, WorkspaceInfo } from "@composio/ao-core"; const execFileAsync = promisify(execFile); async function git(cwd: string, ...args: string[]): Promise { const { stdout } = await execFileAsync("git", args, { cwd }); return stdout.trimEnd(); } describe("workspace-worktree (integration)", () => { let repoDir: string; let worktreeBaseDir: string; let workspace: ReturnType; let project: ProjectConfig; let createdInfo: WorkspaceInfo; beforeAll(async () => { // Create a temp repo with initial commit const rawRepo = await mkdtemp(join(tmpdir(), "ao-inttest-wt-repo-")); repoDir = await realpath(rawRepo); await git(repoDir, "init", "-b", "main"); await git(repoDir, "config", "user.email", "test@test.com"); await git(repoDir, "config", "user.name", "Test"); await execFileAsync("sh", ["-c", "echo hello > README.md"], { cwd: repoDir }); await git(repoDir, "add", "."); await git(repoDir, "commit", "-m", "initial commit"); // Add "origin" pointing at itself so the plugin's fetch succeeds await git(repoDir, "remote", "add", "origin", repoDir); await git(repoDir, "fetch", "origin"); // Create worktree base dir const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-wt-base-")); worktreeBaseDir = await realpath(rawBase); workspace = worktreePlugin.create({ worktreeDir: worktreeBaseDir }); project = { name: "inttest", repo: "test/inttest", path: repoDir, defaultBranch: "main", sessionPrefix: "test", }; }, 30_000); afterAll(async () => { // Clean up worktrees first (must be done before removing repo) try { await git(repoDir, "worktree", "prune"); } catch { /* best-effort cleanup */ } if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {}); if (worktreeBaseDir) await rm(worktreeBaseDir, { recursive: true, force: true }).catch(() => {}); }, 30_000); it("creates a worktree workspace", async () => { createdInfo = await workspace.create({ projectId: "inttest", sessionId: "session-1", project, branch: "feat/test-branch", }); expect(createdInfo.path).toContain("session-1"); expect(createdInfo.branch).toBe("feat/test-branch"); expect(createdInfo.sessionId).toBe("session-1"); expect(createdInfo.projectId).toBe("inttest"); expect(existsSync(createdInfo.path)).toBe(true); }); it("worktree is on the correct branch", async () => { const branch = await git(createdInfo.path, "branch", "--show-current"); expect(branch).toBe("feat/test-branch"); }); it("worktree has the files from main", async () => { expect(existsSync(join(createdInfo.path, "README.md"))).toBe(true); }); it("lists the worktree", async () => { const list = await workspace.list("inttest"); expect(list.length).toBeGreaterThanOrEqual(1); const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1"); expect(found).toBeDefined(); expect(found!.branch).toBe("feat/test-branch"); }); it("rejects invalid projectId", async () => { await expect( workspace.create({ projectId: "../escape", sessionId: "ok", project, branch: "feat/x", }), ).rejects.toThrow("Invalid projectId"); }); it("rejects invalid sessionId", async () => { await expect( workspace.create({ projectId: "inttest", sessionId: "bad/id", project, branch: "feat/x", }), ).rejects.toThrow("Invalid sessionId"); }); it("destroys the worktree", async () => { const pathToDestroy = createdInfo.path; await workspace.destroy(pathToDestroy); expect(existsSync(pathToDestroy)).toBe(false); }); it("list returns empty after destroy", async () => { const list = await workspace.list("inttest"); const found = list.find((w: { sessionId: string }) => w.sessionId === "session-1"); expect(found).toBeUndefined(); }); });