From 16dba100ebe9dcc8f4dbbd1d7e989d46cefb495f Mon Sep 17 00:00:00 2001 From: Prateek Date: Sat, 14 Feb 2026 14:05:50 +0530 Subject: [PATCH] test: add comprehensive unit and integration tests for all 4 plugins Add 136 unit tests and 35 integration tests covering runtime-tmux, runtime-process, workspace-worktree, and workspace-clone plugins. Also adds 13 unit tests for the core plugin-registry. Unit tests (136 total): - runtime-tmux (25): create/destroy, send-keys modes, getOutput, isAlive, getMetrics - runtime-process (40): spawn lifecycle, SIGKILL escalation, output buffering, isolation - workspace-worktree (40): create/destroy, tilde expansion, branch fallback, symlinks - workspace-clone (31): create/destroy, --reference clone, pre-existence check, cleanup Integration tests (35 total): - runtime-tmux (8): full lifecycle with real tmux sessions - runtime-process (11): full lifecycle with real child processes - workspace-worktree (8): real git worktree operations - workspace-clone (8): real git clone operations Plugin-registry tests (13): - register/get/list, config forwarding, slot isolation, loadBuiltins, loadFromConfig Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/plugin-registry.test.ts | 187 +++++ packages/integration-tests/package.json | 6 +- .../src/runtime-process.integration.test.ts | 87 ++ .../src/runtime-tmux.integration.test.ts | 81 ++ .../src/workspace-clone.integration.test.ts | 125 +++ .../workspace-worktree.integration.test.ts | 130 +++ packages/plugins/runtime-process/package.json | 5 +- .../src/__tests__/index.test.ts | 680 +++++++++++++++ packages/plugins/runtime-tmux/package.json | 5 +- .../runtime-tmux/src/__tests__/index.test.ts | 548 +++++++++++++ packages/plugins/workspace-clone/package.json | 5 +- .../src/__tests__/index.test.ts | 677 +++++++++++++++ .../plugins/workspace-worktree/package.json | 5 +- .../src/__tests__/index.test.ts | 771 ++++++++++++++++++ pnpm-lock.yaml | 24 + 15 files changed, 3331 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/__tests__/plugin-registry.test.ts create mode 100644 packages/integration-tests/src/runtime-process.integration.test.ts create mode 100644 packages/integration-tests/src/runtime-tmux.integration.test.ts create mode 100644 packages/integration-tests/src/workspace-clone.integration.test.ts create mode 100644 packages/integration-tests/src/workspace-worktree.integration.test.ts create mode 100644 packages/plugins/runtime-process/src/__tests__/index.test.ts create mode 100644 packages/plugins/runtime-tmux/src/__tests__/index.test.ts create mode 100644 packages/plugins/workspace-clone/src/__tests__/index.test.ts create mode 100644 packages/plugins/workspace-worktree/src/__tests__/index.test.ts diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts new file mode 100644 index 000000000..f328b01e6 --- /dev/null +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createPluginRegistry } from "../plugin-registry.js"; +import type { PluginModule, PluginManifest, OrchestratorConfig } from "../types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePlugin( + slot: PluginManifest["slot"], + name: string, +): PluginModule { + return { + manifest: { + name, + slot, + description: `Test ${slot} plugin: ${name}`, + version: "0.0.1", + }, + create: vi.fn((config?: Record) => ({ + name, + _config: config, + })), + }; +} + +function makeOrchestratorConfig( + overrides?: Partial, +): OrchestratorConfig { + return { + projects: {}, + ...overrides, + } as OrchestratorConfig; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("createPluginRegistry", () => { + it("returns a registry object", () => { + const registry = createPluginRegistry(); + expect(registry).toHaveProperty("register"); + expect(registry).toHaveProperty("get"); + expect(registry).toHaveProperty("list"); + expect(registry).toHaveProperty("loadBuiltins"); + expect(registry).toHaveProperty("loadFromConfig"); + }); +}); + +describe("register + get", () => { + it("registers and retrieves a plugin", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("runtime", "tmux"); + + registry.register(plugin); + + const instance = registry.get<{ name: string }>("runtime", "tmux"); + expect(instance).not.toBeNull(); + expect(instance!.name).toBe("tmux"); + }); + + it("returns null for unregistered plugin", () => { + const registry = createPluginRegistry(); + expect(registry.get("runtime", "nonexistent")).toBeNull(); + }); + + it("passes config to plugin create()", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "worktree"); + + registry.register(plugin, { worktreeDir: "/custom/path" }); + + expect(plugin.create).toHaveBeenCalledWith({ worktreeDir: "/custom/path" }); + const instance = registry.get<{ _config: Record }>( + "workspace", + "worktree", + ); + expect(instance!._config).toEqual({ worktreeDir: "/custom/path" }); + }); + + it("overwrites previously registered plugin with same slot:name", () => { + const registry = createPluginRegistry(); + const plugin1 = makePlugin("runtime", "tmux"); + const plugin2 = makePlugin("runtime", "tmux"); + + registry.register(plugin1); + registry.register(plugin2); + + // Should call create on both + expect(plugin1.create).toHaveBeenCalledTimes(1); + expect(plugin2.create).toHaveBeenCalledTimes(1); + + // get() returns the latest + const instance = registry.get<{ name: string }>("runtime", "tmux"); + expect(instance).not.toBeNull(); + }); + + it("registers plugins in different slots independently", () => { + const registry = createPluginRegistry(); + const runtimePlugin = makePlugin("runtime", "tmux"); + const workspacePlugin = makePlugin("workspace", "worktree"); + + registry.register(runtimePlugin); + registry.register(workspacePlugin); + + expect(registry.get("runtime", "tmux")).not.toBeNull(); + expect(registry.get("workspace", "worktree")).not.toBeNull(); + expect(registry.get("runtime", "worktree")).toBeNull(); + expect(registry.get("workspace", "tmux")).toBeNull(); + }); +}); + +describe("list", () => { + it("lists plugins in a given slot", () => { + const registry = createPluginRegistry(); + registry.register(makePlugin("runtime", "tmux")); + registry.register(makePlugin("runtime", "process")); + registry.register(makePlugin("workspace", "worktree")); + + const runtimes = registry.list("runtime"); + expect(runtimes).toHaveLength(2); + expect(runtimes.map((m) => m.name)).toContain("tmux"); + expect(runtimes.map((m) => m.name)).toContain("process"); + }); + + it("returns empty array for slot with no plugins", () => { + const registry = createPluginRegistry(); + expect(registry.list("notifier")).toEqual([]); + }); + + it("does not return plugins from other slots", () => { + const registry = createPluginRegistry(); + registry.register(makePlugin("runtime", "tmux")); + + expect(registry.list("workspace")).toEqual([]); + }); +}); + +describe("loadBuiltins", () => { + it("silently skips unavailable packages", async () => { + const registry = createPluginRegistry(); + // loadBuiltins tries to import all built-in packages. + // In the test environment, most are not resolvable — should not throw. + await expect(registry.loadBuiltins()).resolves.toBeUndefined(); + }); +}); + +describe("extractPluginConfig (via register with config)", () => { + // extractPluginConfig is tested indirectly: we verify that register() + // correctly passes config through, and that loadBuiltins() would call + // extractPluginConfig for known slot:name pairs. The actual config + // forwarding logic is validated in workspace plugin unit tests. + + it("register passes config to plugin create()", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "worktree"); + + registry.register(plugin, { worktreeDir: "/custom/path" }); + + expect(plugin.create).toHaveBeenCalledWith({ worktreeDir: "/custom/path" }); + }); + + it("register passes undefined config when none provided", () => { + const registry = createPluginRegistry(); + const plugin = makePlugin("workspace", "clone"); + + registry.register(plugin); + + expect(plugin.create).toHaveBeenCalledWith(undefined); + }); +}); + +describe("loadFromConfig", () => { + it("does not throw when no plugins are importable", async () => { + const registry = createPluginRegistry(); + const config = makeOrchestratorConfig({ worktreeDir: "/test" }); + + // loadFromConfig calls loadBuiltins internally, which may fail to + // import packages in the test env — should still succeed gracefully + await expect(registry.loadFromConfig(config)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 66aaf933b..4646879c9 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -14,7 +14,11 @@ "@agent-orchestrator/plugin-agent-claude-code": "workspace:*", "@agent-orchestrator/plugin-agent-codex": "workspace:*", "@agent-orchestrator/plugin-agent-aider": "workspace:*", - "@agent-orchestrator/plugin-agent-opencode": "workspace:*" + "@agent-orchestrator/plugin-agent-opencode": "workspace:*", + "@agent-orchestrator/plugin-runtime-tmux": "workspace:*", + "@agent-orchestrator/plugin-runtime-process": "workspace:*", + "@agent-orchestrator/plugin-workspace-worktree": "workspace:*", + "@agent-orchestrator/plugin-workspace-clone": "workspace:*" }, "devDependencies": { "@types/node": "^25.2.3", diff --git a/packages/integration-tests/src/runtime-process.integration.test.ts b/packages/integration-tests/src/runtime-process.integration.test.ts new file mode 100644 index 000000000..001a52c7a --- /dev/null +++ b/packages/integration-tests/src/runtime-process.integration.test.ts @@ -0,0 +1,87 @@ +import { afterAll, describe, expect, it } from "vitest"; +import processPlugin from "@agent-orchestrator/plugin-runtime-process"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; +import { sleep } from "./helpers/polling.js"; + +describe("runtime-process (integration)", () => { + const runtime = processPlugin.create(); + const sessionId = `proc-inttest-${Date.now()}`; + let handle: RuntimeHandle; + + afterAll(async () => { + try { + await runtime.destroy(handle); + } catch { /* best-effort cleanup */ } + }, 30_000); + + it("creates a child process", async () => { + handle = await runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", // cat echoes stdin to stdout + environment: { AO_TEST: "1" }, + }); + + expect(handle.id).toBe(sessionId); + expect(handle.runtimeName).toBe("process"); + expect(handle.data.pid).toBeTypeOf("number"); + }); + + it("isAlive returns true for running process", async () => { + expect(await runtime.isAlive(handle)).toBe(true); + }); + + it("sendMessage writes to stdin and output is captured", async () => { + await runtime.sendMessage(handle, "hello from test"); + await sleep(200); // give time for stdout to be captured + const output = await runtime.getOutput(handle); + expect(output).toContain("hello from test"); + }); + + it("getMetrics returns uptime", async () => { + const metrics = await runtime.getMetrics!(handle); + expect(metrics.uptimeMs).toBeGreaterThan(0); + }); + + it("getAttachInfo returns PID", async () => { + const info = await runtime.getAttachInfo!(handle); + expect(info.type).toBe("process"); + expect(info.target).toMatch(/^\d+$/); + }); + + it("rejects duplicate session IDs", async () => { + await expect( + runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", + environment: {}, + }), + ).rejects.toThrow("already exists"); + }); + + it("sendMessage throws for unknown session", async () => { + await expect(runtime.sendMessage({ id: "nonexistent", runtimeName: "process", data: {} }, "hi")).rejects.toThrow( + "No process found", + ); + }); + + it("destroy kills the process", async () => { + await runtime.destroy(handle); + await sleep(200); // give time for exit handler + expect(await runtime.isAlive(handle)).toBe(false); + }); + + it("getOutput returns empty for destroyed session", async () => { + const output = await runtime.getOutput(handle); + expect(output).toBe(""); + }); + + it("isAlive returns false for unknown session", async () => { + expect(await runtime.isAlive({ id: "nonexistent", runtimeName: "process", data: {} })).toBe(false); + }); + + it("destroy is idempotent", async () => { + await runtime.destroy(handle); // should not throw + }); +}); diff --git a/packages/integration-tests/src/runtime-tmux.integration.test.ts b/packages/integration-tests/src/runtime-tmux.integration.test.ts new file mode 100644 index 000000000..43fddac5e --- /dev/null +++ b/packages/integration-tests/src/runtime-tmux.integration.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import tmuxPlugin from "@agent-orchestrator/plugin-runtime-tmux"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; +import { isTmuxAvailable, killSessionsByPrefix } from "./helpers/tmux.js"; +import { sleep } from "./helpers/polling.js"; + +const tmuxOk = await isTmuxAvailable(); +const SESSION_PREFIX = "ao-inttest-tmux-"; + +describe.skipIf(!tmuxOk)("runtime-tmux (integration)", () => { + const runtime = tmuxPlugin.create(); + const sessionId = `${SESSION_PREFIX}${Date.now()}`; + let handle: RuntimeHandle; + + beforeAll(async () => { + await killSessionsByPrefix(SESSION_PREFIX); + }, 30_000); + + afterAll(async () => { + try { + await runtime.destroy(handle); + } catch { /* best-effort cleanup */ } + await killSessionsByPrefix(SESSION_PREFIX); + }, 30_000); + + it("creates a tmux session", async () => { + handle = await runtime.create({ + sessionId, + workspacePath: "/tmp", + launchCommand: "cat", // cat will wait for stdin + environment: { AO_TEST: "1" }, + }); + + expect(handle.id).toBe(sessionId); + expect(handle.runtimeName).toBe("tmux"); + }); + + it("isAlive returns true for running session", async () => { + expect(await runtime.isAlive(handle)).toBe(true); + }); + + it("sendMessage sends text and getOutput captures it", async () => { + await runtime.sendMessage(handle, "hello world"); + await sleep(500); // give tmux time to process + const output = await runtime.getOutput(handle); + expect(output).toContain("hello world"); + }); + + it("sendMessage handles long text via buffer", async () => { + const longText = "x".repeat(250); + await runtime.sendMessage(handle, longText); + await sleep(500); + const output = await runtime.getOutput(handle); + // tmux wraps long text at column width, so strip ANSI escapes and newlines + // eslint-disable-next-line no-control-regex + const stripped = output.replace(/\x1b\[[0-9;]*m/g, "").replace(/\n/g, ""); + expect(stripped).toContain(longText); + }); + + it("getMetrics returns uptime", async () => { + const metrics = await runtime.getMetrics!(handle); + expect(metrics.uptimeMs).toBeGreaterThan(0); + }); + + it("getAttachInfo returns tmux command", async () => { + const info = await runtime.getAttachInfo!(handle); + expect(info.type).toBe("tmux"); + expect(info.target).toBe(sessionId); + expect(info.command).toContain("tmux attach"); + }); + + it("destroy kills the session", async () => { + await runtime.destroy(handle); + expect(await runtime.isAlive(handle)).toBe(false); + }); + + it("destroy is idempotent", async () => { + // Should not throw even though session is already dead + await runtime.destroy(handle); + }); +}); diff --git a/packages/integration-tests/src/workspace-clone.integration.test.ts b/packages/integration-tests/src/workspace-clone.integration.test.ts new file mode 100644 index 000000000..8471d9743 --- /dev/null +++ b/packages/integration-tests/src/workspace-clone.integration.test.ts @@ -0,0 +1,125 @@ +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 clonePlugin from "@agent-orchestrator/plugin-workspace-clone"; +import type { ProjectConfig, WorkspaceInfo } from "@agent-orchestrator/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-clone (integration)", () => { + let repoDir: string; + let cloneBaseDir: 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-clone-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"); + + // Clone plugin needs a remote URL — use the local path as origin + await git(repoDir, "remote", "add", "origin", repoDir); + + // Create clone base dir + const rawBase = await mkdtemp(join(tmpdir(), "ao-inttest-clone-base-")); + cloneBaseDir = await realpath(rawBase); + + workspace = clonePlugin.create({ cloneDir: cloneBaseDir }); + + project = { + name: "inttest", + repo: "test/inttest", + path: repoDir, + defaultBranch: "main", + sessionPrefix: "test", + }; + }, 30_000); + + afterAll(async () => { + if (repoDir) await rm(repoDir, { recursive: true, force: true }).catch(() => {}); + if (cloneBaseDir) await rm(cloneBaseDir, { recursive: true, force: true }).catch(() => {}); + }, 30_000); + + it("creates a clone 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("clone is on the correct branch", async () => { + const branch = await git(createdInfo.path, "branch", "--show-current"); + expect(branch).toBe("feat/test-branch"); + }); + + it("clone has the files from main", async () => { + expect(existsSync(join(createdInfo.path, "README.md"))).toBe(true); + }); + + it("rejects duplicate workspace", async () => { + await expect( + workspace.create({ + projectId: "inttest", + sessionId: "session-1", + project, + branch: "feat/other", + }), + ).rejects.toThrow("already exists"); + }); + + it("lists the clone", async () => { + const list = await workspace.list("inttest"); + expect(list.length).toBeGreaterThanOrEqual(1); + const found = list.find((w) => w.sessionId === "session-1"); + expect(found).toBeDefined(); + expect(found!.branch).toBe("feat/test-branch"); + }); + + it("rejects invalid projectId", async () => { + await expect( + workspace.create({ + projectId: "bad/id", + sessionId: "ok", + project, + branch: "feat/x", + }), + ).rejects.toThrow("Invalid projectId"); + }); + + it("destroys the clone", 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) => w.sessionId === "session-1"); + expect(found).toBeUndefined(); + }); +}); diff --git a/packages/integration-tests/src/workspace-worktree.integration.test.ts b/packages/integration-tests/src/workspace-worktree.integration.test.ts new file mode 100644 index 000000000..08da1e8a6 --- /dev/null +++ b/packages/integration-tests/src/workspace-worktree.integration.test.ts @@ -0,0 +1,130 @@ +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 "@agent-orchestrator/plugin-workspace-worktree"; +import type { ProjectConfig, WorkspaceInfo } from "@agent-orchestrator/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) => 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) => w.sessionId === "session-1"); + expect(found).toBeUndefined(); + }); +}); diff --git a/packages/plugins/runtime-process/package.json b/packages/plugins/runtime-process/package.json index 26eb9f683..b32b8bf37 100644 --- a/packages/plugins/runtime-process/package.json +++ b/packages/plugins/runtime-process/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/runtime-process/src/__tests__/index.test.ts b/packages/plugins/runtime-process/src/__tests__/index.test.ts new file mode 100644 index 000000000..1acf49a8c --- /dev/null +++ b/packages/plugins/runtime-process/src/__tests__/index.test.ts @@ -0,0 +1,680 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; + +// --------------------------------------------------------------------------- +// Hoisted mock — must be set up before import +// --------------------------------------------------------------------------- +const { mockSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + spawn: mockSpawn, +})); + +import { create, manifest, default as defaultExport } from "../index.js"; + +// --------------------------------------------------------------------------- +// Mock ChildProcess +// --------------------------------------------------------------------------- +class MockChildProcess extends EventEmitter { + pid = 12345; + exitCode: number | null = null; + signalCode: string | null = null; + stdin = { + writable: true, + write: vi.fn((_data: string, cb: (err?: Error | null) => void) => { + cb(null); + }), + on: vi.fn(), + removeListener: vi.fn(), + }; + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = vi.fn(); +} + +function createMockChild(autoSpawn = true): MockChildProcess { + const child = new MockChildProcess(); + if (autoSpawn) { + // Emit "spawn" on next tick so the await in create() resolves + process.nextTick(() => child.emit("spawn")); + } + return child; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +function makeHandle(id = "test-session"): RuntimeHandle { + return { id, runtimeName: "process", data: { pid: 12345 } }; +} + +function defaultConfig(overrides: Record = {}) { + return { + sessionId: "test-session", + launchCommand: "echo hello", + workspacePath: "/tmp/workspace", + environment: { FOO: "bar" }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +// ========================================================================= +// Manifest & exports +// ========================================================================= +describe("manifest & exports", () => { + it("has correct manifest fields", () => { + expect(manifest).toEqual({ + name: "process", + slot: "runtime", + description: "Runtime plugin: child processes", + version: "0.1.0", + }); + }); + + it("default export is a valid PluginModule", () => { + expect(defaultExport.manifest).toBe(manifest); + expect(typeof defaultExport.create).toBe("function"); + }); + + it("create() returns a runtime with name 'process'", () => { + const runtime = create(); + expect(runtime.name).toBe("process"); + }); +}); + +// ========================================================================= +// runtime.create() +// ========================================================================= +describe("create()", () => { + it("spawns process with shell:true, detached:true, correct cwd and env", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + expect(mockSpawn).toHaveBeenCalledWith( + "echo hello", + expect.objectContaining({ + cwd: "/tmp/workspace", + shell: true, + detached: true, + stdio: ["pipe", "pipe", "pipe"], + }), + ); + + // Check the env includes the config environment merged with process.env + const callArgs = mockSpawn.mock.calls[0][1] as { env: Record }; + expect(callArgs.env.FOO).toBe("bar"); + }); + + it("returns handle with correct id, runtimeName, and pid in data", async () => { + const child = createMockChild(); + child.pid = 99999; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig({ sessionId: "my-session" })); + + expect(handle.id).toBe("my-session"); + expect(handle.runtimeName).toBe("process"); + expect(handle.data).toEqual( + expect.objectContaining({ pid: 99999 }), + ); + }); + + it("rejects invalid session IDs with special characters", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad session!!" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("rejects session ID with dots", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad.session" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("rejects session ID with spaces", async () => { + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "bad session" })), + ).rejects.toThrow(/Invalid session ID/); + }); + + it("accepts valid session IDs with alphanumeric, hyphens, underscores", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig({ sessionId: "my-session_01" })); + expect(handle.id).toBe("my-session_01"); + }); + + it("rejects duplicate session IDs", async () => { + const child1 = createMockChild(); + mockSpawn.mockReturnValue(child1); + + const runtime = create(); + await runtime.create(defaultConfig({ sessionId: "dup-session" })); + + // Second call with same ID should throw + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + await expect( + runtime.create(defaultConfig({ sessionId: "dup-session" })), + ).rejects.toThrow(/already exists/); + }); + + it("cleans up on spawn error", async () => { + const child = createMockChild(false); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const createPromise = runtime.create(defaultConfig({ sessionId: "fail-session" })); + + // Emit error on next tick + process.nextTick(() => child.emit("error", new Error("ENOENT"))); + + await expect(createPromise).rejects.toThrow(/Failed to spawn/); + + // After the error, the session ID should be cleaned up from internal map. + // We can verify by trying to create with the same ID again (should succeed). + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + const handle = await runtime.create(defaultConfig({ sessionId: "fail-session" })); + expect(handle.id).toBe("fail-session"); + }); + + it("cleans up when spawn() itself throws synchronously", async () => { + mockSpawn.mockImplementation(() => { + throw new Error("spawn EACCES"); + }); + + const runtime = create(); + await expect( + runtime.create(defaultConfig({ sessionId: "sync-fail" })), + ).rejects.toThrow(/Failed to spawn/); + + // Slot should be freed — re-create should work + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + const handle = await runtime.create(defaultConfig({ sessionId: "sync-fail" })); + expect(handle.id).toBe("sync-fail"); + }); +}); + +// ========================================================================= +// destroy() +// ========================================================================= +describe("destroy()", () => { + it("kills the process and resolves after exit", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + // Simulate exit after SIGTERM + child.kill.mockImplementation(() => { + // ignored — process.kill(-pid) is used instead + }); + + // When destroy is called, it sends SIGTERM then waits for exit. + // We need to emit exit when the process receives the signal. + const destroyPromise = runtime.destroy(handle); + + // Give the event loop a tick for destroy to register its "exit" listener + await new Promise((r) => setTimeout(r, 10)); + child.exitCode = 0; + child.emit("exit", 0, null); + + await destroyPromise; + + // process.kill(-pid, "SIGTERM") should have been called + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGTERM"); + + killSpy.mockRestore(); + }); + + it("does not throw for unknown handle (no-op)", async () => { + const runtime = create(); + await expect( + runtime.destroy(makeHandle("nonexistent")), + ).resolves.toBeUndefined(); + }); + + it("does not attempt kill if process already exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + // Simulate the process having exited already + child.exitCode = 0; + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + await runtime.destroy(handle); + + // Should NOT have called process.kill since process already exited + expect(killSpy).not.toHaveBeenCalled(); + expect(child.kill).not.toHaveBeenCalled(); + + killSpy.mockRestore(); + }); + + it("escalates to SIGKILL after 5 second timeout", async () => { + vi.useFakeTimers(); + + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + + // We need to emit "spawn" manually with fake timers + const createPromise = runtime.create(defaultConfig({ sessionId: "kill-timeout" })); + await vi.runAllTimersAsync(); + // "spawn" was scheduled via nextTick in createMockChild, which ran + const handle = await createPromise; + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + const destroyPromise = runtime.destroy(handle); + + // Advance past the 5-second timeout — process never exits + await vi.advanceTimersByTimeAsync(5100); + + await destroyPromise; + + // Should have called SIGTERM first, then SIGKILL + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(killSpy).toHaveBeenCalledWith(-12345, "SIGKILL"); + + killSpy.mockRestore(); + vi.useRealTimers(); + }); + + it("falls back to child.kill when process.kill(-pid) throws", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw new Error("ESRCH"); + }); + + const destroyPromise = runtime.destroy(handle); + + await new Promise((r) => setTimeout(r, 10)); + child.exitCode = 0; + child.emit("exit", 0, null); + + await destroyPromise; + + // process.kill threw, so child.kill should have been called as fallback + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + + killSpy.mockRestore(); + }); +}); + +// ========================================================================= +// sendMessage() +// ========================================================================= +describe("sendMessage()", () => { + it("writes message with trailing newline to stdin", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await runtime.sendMessage(handle, "hello world"); + + expect(child.stdin.write).toHaveBeenCalledWith( + "hello world\n", + expect.any(Function), + ); + }); + + it("throws for unknown session", async () => { + const runtime = create(); + await expect( + runtime.sendMessage(makeHandle("nonexistent"), "hello"), + ).rejects.toThrow(/No process found/); + }); + + it("throws when stdin is not writable", async () => { + const child = createMockChild(); + child.stdin.writable = false; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await expect( + runtime.sendMessage(handle, "hello"), + ).rejects.toThrow(/stdin not writable/); + }); + + it("rejects when stdin.write returns an error", async () => { + const child = createMockChild(); + child.stdin.write = vi.fn((_data: string, cb: (err?: Error | null) => void) => { + cb(new Error("write EPIPE")); + }); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + const handle = await runtime.create(defaultConfig()); + + await expect( + runtime.sendMessage(handle, "hello"), + ).rejects.toThrow(/write EPIPE/); + }); +}); + +// ========================================================================= +// getOutput() +// ========================================================================= +describe("getOutput()", () => { + it("returns buffered output lines", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Simulate stdout data + child.stdout.emit("data", Buffer.from("line1\nline2\nline3")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("line1\nline2\nline3"); + }); + + it("returns only the requested number of lines", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stdout.emit("data", Buffer.from("a\nb\nc\nd\ne")); + + const output = await runtime.getOutput(makeHandle(), 2); + expect(output).toBe("d\ne"); + }); + + it("returns empty string for unknown session", async () => { + const runtime = create(); + const output = await runtime.getOutput(makeHandle("nonexistent"), 50); + expect(output).toBe(""); + }); + + it("captures stderr in the output buffer too", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stderr.emit("data", Buffer.from("error output")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("error output"); + }); + + it("interleaves stdout and stderr", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.stdout.emit("data", Buffer.from("out1")); + child.stderr.emit("data", Buffer.from("err1")); + child.stdout.emit("data", Buffer.from("out2")); + + const output = await runtime.getOutput(makeHandle(), 50); + expect(output).toBe("out1\nerr1\nout2"); + }); +}); + +// ========================================================================= +// isAlive() +// ========================================================================= +describe("isAlive()", () => { + it("returns true when process is running (exitCode and signalCode null)", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + expect(await runtime.isAlive(makeHandle())).toBe(true); + }); + + it("returns false when process has exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.exitCode = 1; + + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("returns false when process was signalled", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.signalCode = "SIGTERM"; + + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("returns false for unknown session", async () => { + const runtime = create(); + expect(await runtime.isAlive(makeHandle("nonexistent"))).toBe(false); + }); +}); + +// ========================================================================= +// getMetrics() +// ========================================================================= +describe("getMetrics()", () => { + it("returns uptimeMs for a running session", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + + await runtime.create(defaultConfig()); + + // Small delay to ensure uptime > 0 + await new Promise((r) => setTimeout(r, 10)); + + const metrics = await runtime.getMetrics!(makeHandle()); + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(5000); + }); + + it("returns uptimeMs for unknown session (uses Date.now as fallback)", async () => { + const runtime = create(); + const metrics = await runtime.getMetrics!(makeHandle("nonexistent")); + // When entry is null, createdAt defaults to Date.now(), so uptimeMs is ~0 + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(100); + }); +}); + +// ========================================================================= +// getAttachInfo() +// ========================================================================= +describe("getAttachInfo()", () => { + it("returns PID as target when process is running", async () => { + const child = createMockChild(); + child.pid = 54321; + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe("54321"); + }); + + it("returns 'no longer running' command when process has exited", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.exitCode = 0; + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); + + it("returns 'no longer running' for unknown session", async () => { + const runtime = create(); + const info = await runtime.getAttachInfo!(makeHandle("nonexistent")); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); + + it("returns 'no longer running' when process was killed by signal", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + child.signalCode = "SIGKILL"; + + const info = await runtime.getAttachInfo!(makeHandle()); + expect(info.type).toBe("process"); + expect(info.target).toBe(""); + expect(info.command).toContain("no longer running"); + }); +}); + +// ========================================================================= +// Exit handler cleans up internal map +// ========================================================================= +describe("exit handler", () => { + it("removes session from internal map when process exits", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Process is alive + expect(await runtime.isAlive(makeHandle())).toBe(true); + + // Simulate exit + child.exitCode = 0; + child.emit("exit", 0, null); + + // After exit, session should be gone from the map + expect(await runtime.isAlive(makeHandle())).toBe(false); + }); + + it("allows re-creating a session after exit cleanup", async () => { + const child1 = createMockChild(); + mockSpawn.mockReturnValue(child1); + + const runtime = create(); + await runtime.create(defaultConfig({ sessionId: "reuse-me" })); + + // Simulate exit + child1.exitCode = 0; + child1.emit("exit", 0, null); + + // Re-create with same ID should work + const child2 = createMockChild(); + mockSpawn.mockReturnValue(child2); + const handle = await runtime.create(defaultConfig({ sessionId: "reuse-me" })); + expect(handle.id).toBe("reuse-me"); + }); +}); + +// ========================================================================= +// Output buffer truncation +// ========================================================================= +describe("output buffer truncation", () => { + it("truncates output buffer to MAX_OUTPUT_LINES (1000)", async () => { + const child = createMockChild(); + mockSpawn.mockReturnValue(child); + + const runtime = create(); + await runtime.create(defaultConfig()); + + // Generate 1200 lines + const lines = Array.from({ length: 1200 }, (_, i) => `line-${i}`).join("\n"); + child.stdout.emit("data", Buffer.from(lines)); + + // Request all lines — should be capped at 1000 + const output = await runtime.getOutput(makeHandle(), 2000); + const outputLines = output.split("\n"); + expect(outputLines.length).toBeLessThanOrEqual(1000); + // Should contain the last line + expect(outputLines[outputLines.length - 1]).toBe("line-1199"); + // Should NOT contain the first lines (they were truncated) + expect(output).not.toContain("line-0\n"); + }); +}); + +// ========================================================================= +// Per-instance isolation +// ========================================================================= +describe("per-instance isolation", () => { + it("each create() call gets its own isolated processes map", async () => { + const child1 = createMockChild(); + child1.pid = 11111; + + const runtime1 = create(); + const runtime2 = create(); + + mockSpawn.mockReturnValue(child1); + await runtime1.create(defaultConfig({ sessionId: "session-a" })); + + const child2 = createMockChild(); + child2.pid = 99999; + mockSpawn.mockReturnValue(child2); + await runtime2.create(defaultConfig({ sessionId: "session-a" })); + + // Both runtimes can have the same session ID independently + expect(await runtime1.isAlive(makeHandle("session-a"))).toBe(true); + expect(await runtime2.isAlive(makeHandle("session-a"))).toBe(true); + }); +}); diff --git a/packages/plugins/runtime-tmux/package.json b/packages/plugins/runtime-tmux/package.json index e22921bde..149ea2406 100644 --- a/packages/plugins/runtime-tmux/package.json +++ b/packages/plugins/runtime-tmux/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/runtime-tmux/src/__tests__/index.test.ts b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts new file mode 100644 index 000000000..ba4b4e132 --- /dev/null +++ b/packages/plugins/runtime-tmux/src/__tests__/index.test.ts @@ -0,0 +1,548 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import type { RuntimeHandle } from "@agent-orchestrator/core"; + +// Mock node:child_process with custom promisify support +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + // promisify(execFile) checks for a custom promisify symbol. Set it so + // await execFileAsync(...) returns { stdout, stderr } properly. + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +// Mock node:crypto for deterministic UUIDs +vi.mock("node:crypto", () => ({ + randomUUID: () => "test-uuid-1234", +})); + +// Mock node:fs for writeFileSync / unlinkSync +vi.mock("node:fs", () => ({ + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +// Get reference to the promisify-custom mock — this is what the plugin actually calls +const mockExecFileCustom = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +/** Queue a successful tmux command with the given stdout. */ +function mockTmuxSuccess(stdout = "") { + mockExecFileCustom.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +/** Queue a failed tmux command. */ +function mockTmuxError(message: string) { + mockExecFileCustom.mockRejectedValueOnce(new Error(message)); +} + +/** Create a RuntimeHandle for testing. */ +function makeHandle(id: string, createdAt?: number): RuntimeHandle { + return { + id, + runtimeName: "tmux", + data: { + createdAt: createdAt ?? 1000, + workspacePath: "/tmp/workspace", + }, + }; +} + +// Import after mocks are set up +import tmuxPlugin, { manifest, create } from "../index.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("manifest", () => { + it("has name 'tmux' and slot 'runtime'", () => { + expect(manifest.name).toBe("tmux"); + expect(manifest.slot).toBe("runtime"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Runtime plugin: tmux sessions"); + }); + + it("default export includes manifest and create", () => { + expect(tmuxPlugin.manifest).toBe(manifest); + expect(tmuxPlugin.create).toBe(create); + }); +}); + +describe("create()", () => { + it("returns a Runtime with name 'tmux'", () => { + const runtime = create(); + expect(runtime.name).toBe("tmux"); + }); +}); + +describe("runtime.create()", () => { + it("calls new-session with correct args", async () => { + const runtime = create(); + + // 1: new-session, 2: send-keys (launch command) + mockTmuxSuccess(); + mockTmuxSuccess(); + + const handle = await runtime.create({ + sessionId: "test-session", + workspacePath: "/tmp/workspace", + launchCommand: "echo hello", + environment: {}, + }); + + expect(handle.id).toBe("test-session"); + expect(handle.runtimeName).toBe("tmux"); + expect(handle.data.workspacePath).toBe("/tmp/workspace"); + + // First call: new-session + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "new-session", + "-d", + "-s", + "test-session", + "-c", + "/tmp/workspace", + ]); + }); + + it("includes -e KEY=VALUE flags for environment variables", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "env-session", + workspacePath: "/tmp/ws", + launchCommand: "bash", + environment: { AO_SESSION: "env-session", FOO: "bar" }, + }); + + // First call: new-session with env args + const firstCallArgs = mockExecFileCustom.mock.calls[0]; + const args = firstCallArgs[1] as string[]; + expect(args).toContain("-e"); + expect(args).toContain("AO_SESSION=env-session"); + expect(args).toContain("FOO=bar"); + }); + + it("sends launch command via send-keys", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "launch-test", + workspacePath: "/tmp/ws", + launchCommand: "claude --session abc", + environment: {}, + }); + + // Second call: send-keys with the launch command + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "send-keys", + "-t", + "launch-test", + "claude --session abc", + "Enter", + ]); + }); + + it("cleans up session if send-keys fails", async () => { + const runtime = create(); + + // 1: new-session succeeds + mockTmuxSuccess(); + // 2: send-keys fails + mockTmuxError("send-keys failed"); + // 3: kill-session (cleanup attempt) + mockTmuxSuccess(); + + await expect( + runtime.create({ + sessionId: "fail-session", + workspacePath: "/tmp/ws", + launchCommand: "bad-command", + environment: {}, + }), + ).rejects.toThrow('Failed to send launch command to session "fail-session"'); + + // Verify kill-session was called for cleanup + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "kill-session", + "-t", + "fail-session", + ]); + }); + + it("rejects invalid session IDs with special characters", async () => { + const runtime = create(); + + await expect( + runtime.create({ + sessionId: "bad session!", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }), + ).rejects.toThrow('Invalid session ID "bad session!"'); + }); + + it("rejects session IDs with dots", async () => { + const runtime = create(); + + await expect( + runtime.create({ + sessionId: "bad.session", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }), + ).rejects.toThrow("Invalid session ID"); + }); + + it("accepts valid session IDs with hyphens and underscores", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + const handle = await runtime.create({ + sessionId: "valid-session_123", + workspacePath: "/tmp/ws", + launchCommand: "echo", + environment: {}, + }); + + expect(handle.id).toBe("valid-session_123"); + }); + + it("handles no environment (undefined)", async () => { + const runtime = create(); + + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.create({ + sessionId: "no-env", + workspacePath: "/tmp/ws", + launchCommand: "echo hi", + } as any); + + // First call should not contain -e flags + const firstCallArgs = mockExecFileCustom.mock.calls[0][1] as string[]; + expect(firstCallArgs).toEqual([ + "new-session", + "-d", + "-s", + "no-env", + "-c", + "/tmp/ws", + ]); + }); +}); + +describe("runtime.destroy()", () => { + it("calls kill-session with the handle id", async () => { + const runtime = create(); + const handle = makeHandle("destroy-test"); + + mockTmuxSuccess(); + + await runtime.destroy(handle); + + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "kill-session", + "-t", + "destroy-test", + ]); + }); + + it("does not throw if session is already gone", async () => { + const runtime = create(); + const handle = makeHandle("already-dead"); + + mockTmuxError("session not found: already-dead"); + + // Should not throw + await expect(runtime.destroy(handle)).resolves.toBeUndefined(); + }); +}); + +describe("runtime.sendMessage()", () => { + it("sends short text with send-keys -l (literal) + Enter", async () => { + const runtime = create(); + const handle = makeHandle("msg-short"); + + // 1: send-keys C-u (clear), 2: send-keys -l text, 3: send-keys Enter + mockTmuxSuccess(); + mockTmuxSuccess(); + mockTmuxSuccess(); + + await runtime.sendMessage(handle, "hello world"); + + expect(mockExecFileCustom).toHaveBeenCalledTimes(3); + + // Call 0: Clear partial input + expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ + "send-keys", + "-t", + "msg-short", + "C-u", + ]); + + // Call 1: Literal text + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "send-keys", + "-t", + "msg-short", + "-l", + "hello world", + ]); + + // Call 2: Enter + expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ + "send-keys", + "-t", + "msg-short", + "Enter", + ]); + }); + + it("uses load-buffer + paste-buffer for long text (> 200 chars)", async () => { + const runtime = create(); + const handle = makeHandle("msg-long"); + const longText = "x".repeat(250); + + // 1: C-u, 2: load-buffer, 3: paste-buffer, 4: unlinkSync (sync), 5: delete-buffer, 6: Enter + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer + mockTmuxSuccess(); // paste-buffer + mockTmuxSuccess(); // delete-buffer (finally block) + mockTmuxSuccess(); // Enter + + await runtime.sendMessage(handle, longText); + + expect(mockExecFileCustom).toHaveBeenCalledTimes(5); + + // Call 0: clear + expect(mockExecFileCustom).toHaveBeenNthCalledWith(1, "tmux", [ + "send-keys", + "-t", + "msg-long", + "C-u", + ]); + + // Call 1: load-buffer with named buffer + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ]); + + // Call 2: paste-buffer + expect(mockExecFileCustom).toHaveBeenNthCalledWith(3, "tmux", [ + "paste-buffer", + "-b", + "ao-test-uuid-1234", + "-t", + "msg-long", + "-d", + ]); + + // Verify writeFileSync was called with the message + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + longText, + { encoding: "utf-8", mode: 0o600 }, + ); + + // Verify unlinkSync was called for cleanup + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ); + }); + + it("uses load-buffer for multiline text", async () => { + const runtime = create(); + const handle = makeHandle("msg-multi"); + + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer + mockTmuxSuccess(); // paste-buffer + mockTmuxSuccess(); // delete-buffer (finally) + mockTmuxSuccess(); // Enter + + await runtime.sendMessage(handle, "line1\nline2\nline3"); + + // Should use buffer path, not send-keys -l + expect(mockExecFileCustom).toHaveBeenNthCalledWith(2, "tmux", [ + "load-buffer", + "-b", + "ao-test-uuid-1234", + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ]); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + "line1\nline2\nline3", + { encoding: "utf-8", mode: 0o600 }, + ); + }); + + it("cleans up buffer and temp file on paste failure", async () => { + const runtime = create(); + const handle = makeHandle("msg-fail"); + const longText = "y".repeat(250); + + mockTmuxSuccess(); // C-u + mockTmuxSuccess(); // load-buffer succeeds + mockTmuxError("paste-buffer failed"); // paste-buffer fails + // finally block: + // unlinkSync is sync (mocked) + mockTmuxSuccess(); // delete-buffer in finally + // After finally, the error propagates — no Enter call + + await expect(runtime.sendMessage(handle, longText)).rejects.toThrow("paste-buffer failed"); + + // unlinkSync should still be called for temp file cleanup + expect(fs.unlinkSync).toHaveBeenCalledWith( + expect.stringContaining("ao-send-test-uuid-1234.txt"), + ); + + // delete-buffer should be called in finally block + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "delete-buffer", + "-b", + "ao-test-uuid-1234", + ]); + }); +}); + +describe("runtime.getOutput()", () => { + it("calls capture-pane with correct args and default lines", async () => { + const runtime = create(); + const handle = makeHandle("output-test"); + + mockTmuxSuccess("some output\nfrom tmux"); + + const output = await runtime.getOutput(handle); + + expect(output).toBe("some output\nfrom tmux"); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "capture-pane", + "-t", + "output-test", + "-p", + "-S", + "-50", + ]); + }); + + it("passes custom line count", async () => { + const runtime = create(); + const handle = makeHandle("output-custom"); + + mockTmuxSuccess("output"); + + await runtime.getOutput(handle, 100); + + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "capture-pane", + "-t", + "output-custom", + "-p", + "-S", + "-100", + ]); + }); + + it("returns empty string on error", async () => { + const runtime = create(); + const handle = makeHandle("output-err"); + + mockTmuxError("session not found"); + + const output = await runtime.getOutput(handle); + + expect(output).toBe(""); + }); +}); + +describe("runtime.isAlive()", () => { + it("returns true when has-session succeeds", async () => { + const runtime = create(); + const handle = makeHandle("alive-test"); + + mockTmuxSuccess(); + + const alive = await runtime.isAlive(handle); + + expect(alive).toBe(true); + expect(mockExecFileCustom).toHaveBeenCalledWith("tmux", [ + "has-session", + "-t", + "alive-test", + ]); + }); + + it("returns false when has-session fails", async () => { + const runtime = create(); + const handle = makeHandle("dead-test"); + + mockTmuxError("session not found"); + + const alive = await runtime.isAlive(handle); + + expect(alive).toBe(false); + }); +}); + +describe("runtime.getMetrics()", () => { + it("returns uptimeMs based on createdAt", async () => { + const runtime = create(); + const now = Date.now(); + const handle = makeHandle("metrics-test", now - 5000); + + const metrics = await runtime.getMetrics!(handle); + + // uptimeMs should be approximately 5000ms (allow some wiggle room) + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(5000); + expect(metrics.uptimeMs).toBeLessThan(6000); + }); + + it("handles missing createdAt by using Date.now()", async () => { + const runtime = create(); + const handle: RuntimeHandle = { + id: "metrics-no-created", + runtimeName: "tmux", + data: {}, + }; + + const metrics = await runtime.getMetrics!(handle); + + // uptimeMs should be very close to 0 since createdAt defaults to Date.now() + expect(metrics.uptimeMs).toBeGreaterThanOrEqual(0); + expect(metrics.uptimeMs).toBeLessThan(1000); + }); +}); + +describe("runtime.getAttachInfo()", () => { + it("returns tmux type and attach command", async () => { + const runtime = create(); + const handle = makeHandle("attach-test"); + + const info = await runtime.getAttachInfo!(handle); + + expect(info).toEqual({ + type: "tmux", + target: "attach-test", + command: "tmux attach -t attach-test", + }); + }); +}); diff --git a/packages/plugins/workspace-clone/package.json b/packages/plugins/workspace-clone/package.json index 3553d6e6c..d88f7bc02 100644 --- a/packages/plugins/workspace-clone/package.json +++ b/packages/plugins/workspace-clone/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/workspace-clone/src/__tests__/index.test.ts b/packages/plugins/workspace-clone/src/__tests__/index.test.ts new file mode 100644 index 000000000..eb9a73187 --- /dev/null +++ b/packages/plugins/workspace-clone/src/__tests__/index.test.ts @@ -0,0 +1,677 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import type { ProjectConfig } from "@agent-orchestrator/core"; + +// Mock node:child_process with custom promisify support +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +// Mock node:fs +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +// Mock node:os +vi.mock("node:os", () => ({ + homedir: () => "/mock-home", +})); + +// Get reference to the promisify-custom mock — this is what the plugin actually calls +const mockExecFileAsync = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +/** Queue a successful git command with the given stdout. */ +function mockGitSuccess(stdout: string) { + mockExecFileAsync.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +/** Queue a failed git command. */ +function mockGitError(message: string) { + mockExecFileAsync.mockRejectedValueOnce(new Error(message)); +} + +/** Create a ProjectConfig for testing. */ +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +// Import after mocks are set up +import clonePlugin, { manifest, create } from "../index.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// manifest +// --------------------------------------------------------------------------- +describe("manifest", () => { + it("has name 'clone' and slot 'workspace'", () => { + expect(manifest.name).toBe("clone"); + expect(manifest.slot).toBe("workspace"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Workspace plugin: git clone isolation"); + }); + + it("default export includes manifest and create", () => { + expect(clonePlugin.manifest).toBe(manifest); + expect(clonePlugin.create).toBe(create); + }); +}); + +// --------------------------------------------------------------------------- +// create() factory +// --------------------------------------------------------------------------- +describe("create()", () => { + it("returns a Workspace with name 'clone'", () => { + const workspace = create(); + expect(workspace.name).toBe("clone"); + }); + + it("uses ~/.ao-clones as default base dir", async () => { + const workspace = create(); + + // Setup: remote URL lookup + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync: workspace path does not exist yet + (fs.existsSync as ReturnType).mockReturnValue(false); + // git clone + mockGitSuccess(""); + // git checkout -b + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "myproject", + sessionId: "session-1", + branch: "feat/test", + project: makeProject(), + }); + + expect(info.path).toBe("/mock-home/.ao-clones/myproject/session-1"); + }); + + it("uses custom cloneDir when configured", async () => { + const workspace = create({ cloneDir: "~/custom-clones" }); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "myproject", + sessionId: "session-2", + branch: "feat/custom", + project: makeProject(), + }); + + expect(info.path).toBe("/mock-home/custom-clones/myproject/session-2"); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.create() +// --------------------------------------------------------------------------- +describe("workspace.create()", () => { + it("gets remote URL via git remote get-url origin", async () => { + const workspace = create(); + + // 1: git remote get-url origin + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // 2: git clone + mockGitSuccess(""); + // 3: git checkout -b + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + // First call should be git remote get-url origin + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["remote", "get-url", "origin"], + { cwd: "/repo/path" }, + ); + }); + + it("falls back to local path when remote URL lookup fails", async () => { + const workspace = create(); + + // 1: git remote get-url origin FAILS + mockGitError("fatal: not a git repository"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // 2: git clone (uses local path as remoteUrl) + mockGitSuccess(""); + // 3: git checkout -b + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + // Clone should use local path as the remote URL + const cloneCall = mockExecFileAsync.mock.calls[1]; + expect(cloneCall[0]).toBe("git"); + const cloneArgs = cloneCall[1] as string[]; + // The remote URL argument (after --reference repoPath --branch defaultBranch) is the 6th arg + expect(cloneArgs[5]).toBe("/repo/path"); + }); + + it("calls git clone with --reference flag", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject({ defaultBranch: "develop" }), + }); + + // The clone call (second call overall) + expect(mockExecFileAsync).toHaveBeenNthCalledWith(2, "git", [ + "clone", + "--reference", + "/repo/path", + "--branch", + "develop", + "https://github.com/test/repo.git", + "/mock-home/.ao-clones/proj/sess", + ]); + }); + + it("creates feature branch via checkout -b", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b feat/new-branch + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/new-branch", + project: makeProject(), + }); + + // Third call: checkout -b + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 3, + "git", + ["checkout", "-b", "feat/new-branch"], + { cwd: "/mock-home/.ao-clones/proj/sess" }, + ); + }); + + it("falls back to plain checkout when branch already exists", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + // git checkout -b fails (branch exists) + mockGitError("fatal: A branch named 'feat/existing' already exists"); + // git checkout (plain) succeeds + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/existing", + project: makeProject(), + }); + + // Fourth call: plain checkout + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 4, + "git", + ["checkout", "feat/existing"], + { cwd: "/mock-home/.ao-clones/proj/sess" }, + ); + + expect(info.branch).toBe("feat/existing"); + }); + + it("cleans up partial clone on clone failure", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync: first call for "already exists" check => false + // second call inside catch for cleanup check => true + (fs.existsSync as ReturnType) + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + // git clone fails + mockGitError("fatal: could not read from remote repository"); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Failed to clone repo for session "sess"'); + + // rmSync should be called to clean up + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("cleans up clone on checkout failure and throws", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + // git clone succeeds + mockGitSuccess(""); + // git checkout -b fails + mockGitError("error: pathspec did not match"); + // git checkout (fallback) also fails + mockGitError("error: pathspec 'bad-branch' did not match"); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "bad-branch", + project: makeProject(), + }), + ).rejects.toThrow('Failed to checkout branch "bad-branch" in clone'); + + // rmSync should be called to clean up the orphaned clone + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("throws if workspace path already exists", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + // existsSync returns true — path already exists + (fs.existsSync as ReturnType).mockReturnValue(true); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow( + 'Workspace path "/mock-home/.ao-clones/proj/sess" already exists for session "sess"', + ); + }); + + it("rejects invalid projectId with special characters", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "bad/project", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid projectId "bad/project"'); + }); + + it("rejects projectId with dots", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "bad.project", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid projectId "bad.project"'); + }); + + it("rejects invalid sessionId with special characters", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "bad session!", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid sessionId "bad session!"'); + }); + + it("rejects sessionId with path traversal", async () => { + const workspace = create(); + + await expect( + workspace.create({ + projectId: "proj", + sessionId: "../escape", + branch: "feat/branch", + project: makeProject(), + }), + ).rejects.toThrow('Invalid sessionId "../escape"'); + }); + + it("returns correct WorkspaceInfo", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + const info = await workspace.create({ + projectId: "my-project", + sessionId: "session-42", + branch: "feat/awesome", + project: makeProject(), + }); + + expect(info).toEqual({ + path: "/mock-home/.ao-clones/my-project/session-42", + branch: "feat/awesome", + sessionId: "session-42", + projectId: "my-project", + }); + }); + + it("creates project clone directory with recursive option", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject(), + }); + + expect(fs.mkdirSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj", { recursive: true }); + }); + + it("expands ~ in project path", async () => { + const workspace = create(); + + mockGitSuccess("https://github.com/test/repo.git"); + (fs.existsSync as ReturnType).mockReturnValue(false); + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.create({ + projectId: "proj", + sessionId: "sess", + branch: "feat/branch", + project: makeProject({ path: "~/my-repos/project" }), + }); + + // git remote get-url should use expanded path + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["remote", "get-url", "origin"], + { cwd: "/mock-home/my-repos/project" }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.destroy() +// --------------------------------------------------------------------------- +describe("workspace.destroy()", () => { + it("removes directory with rmSync when it exists", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + await workspace.destroy("/mock-home/.ao-clones/proj/sess"); + + expect(fs.rmSync).toHaveBeenCalledWith("/mock-home/.ao-clones/proj/sess", { + recursive: true, + force: true, + }); + }); + + it("does not throw when directory does not exist", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(false); + + await expect( + workspace.destroy("/mock-home/.ao-clones/proj/nonexistent"), + ).resolves.toBeUndefined(); + + expect(fs.rmSync).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.list() +// --------------------------------------------------------------------------- +describe("workspace.list()", () => { + it("returns empty array when project directory does not exist", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(false); + + const result = await workspace.list("myproject"); + + expect(result).toEqual([]); + }); + + it("returns workspace entries with branch info", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + // git branch --show-current for each directory + mockGitSuccess("feat/feature-a"); + mockGitSuccess("feat/feature-b"); + + const result = await workspace.list("myproject"); + + expect(result).toEqual([ + { + path: "/mock-home/.ao-clones/myproject/session-1", + branch: "feat/feature-a", + sessionId: "session-1", + projectId: "myproject", + }, + { + path: "/mock-home/.ao-clones/myproject/session-2", + branch: "feat/feature-b", + sessionId: "session-2", + projectId: "myproject", + }, + ]); + + // Verify git branch --show-current was called for each entry + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 1, + "git", + ["branch", "--show-current"], + { cwd: "/mock-home/.ao-clones/myproject/session-1" }, + ); + expect(mockExecFileAsync).toHaveBeenNthCalledWith( + 2, + "git", + ["branch", "--show-current"], + { cwd: "/mock-home/.ao-clones/myproject/session-2" }, + ); + }); + + it("skips non-directory entries", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "session-1", isDirectory: () => true }, + { name: "some-file.txt", isDirectory: () => false }, + { name: ".DS_Store", isDirectory: () => false }, + ]); + + // Only one git call for the single directory + mockGitSuccess("main"); + + const result = await workspace.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-1"); + expect(mockExecFileAsync).toHaveBeenCalledTimes(1); + }); + + it("skips invalid git repos with console warning", async () => { + const workspace = create(); + (fs.existsSync as ReturnType).mockReturnValue(true); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + (fs.readdirSync as ReturnType).mockReturnValue([ + { name: "valid-session", isDirectory: () => true }, + { name: "corrupt-session", isDirectory: () => true }, + ]); + + // First directory succeeds + mockGitSuccess("feat/working"); + // Second directory fails (not a valid git repo) + mockGitError("fatal: not a git repository"); + + const result = await workspace.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("valid-session"); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[workspace-clone] Skipping "corrupt-session"'), + ); + + warnSpy.mockRestore(); + }); + + it("rejects invalid projectId with special characters", async () => { + const workspace = create(); + + await expect(workspace.list("bad/project")).rejects.toThrow( + 'Invalid projectId "bad/project"', + ); + }); + + it("rejects projectId with spaces", async () => { + const workspace = create(); + + await expect(workspace.list("bad project")).rejects.toThrow( + 'Invalid projectId "bad project"', + ); + }); +}); + +// --------------------------------------------------------------------------- +// workspace.postCreate() +// --------------------------------------------------------------------------- +describe("workspace.postCreate()", () => { + it("runs each postCreate command via sh -c", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject({ + postCreate: ["pnpm install", "pnpm build"], + }); + + // Two commands + mockGitSuccess(""); + mockGitSuccess(""); + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).toHaveBeenCalledTimes(2); + + expect(mockExecFileAsync).toHaveBeenNthCalledWith(1, "sh", ["-c", "pnpm install"], { + cwd: "/mock-home/.ao-clones/proj/sess", + }); + + expect(mockExecFileAsync).toHaveBeenNthCalledWith(2, "sh", ["-c", "pnpm build"], { + cwd: "/mock-home/.ao-clones/proj/sess", + }); + }); + + it("does nothing when postCreate is undefined", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject(); // no postCreate + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + it("does nothing when postCreate is empty array", async () => { + const workspace = create(); + + const info = { + path: "/mock-home/.ao-clones/proj/sess", + branch: "feat/branch", + sessionId: "sess", + projectId: "proj", + }; + + const project = makeProject({ postCreate: [] }); + + await workspace.postCreate!(info, project); + + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/workspace-worktree/package.json b/packages/plugins/workspace-worktree/package.json index abf15e7b3..be7816a13 100644 --- a/packages/plugins/workspace-worktree/package.json +++ b/packages/plugins/workspace-worktree/package.json @@ -14,6 +14,8 @@ "scripts": { "build": "tsc", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "clean": "rm -rf dist" }, "dependencies": { @@ -21,6 +23,7 @@ }, "devDependencies": { "@types/node": "^25.2.3", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^3.0.0" } } diff --git a/packages/plugins/workspace-worktree/src/__tests__/index.test.ts b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts new file mode 100644 index 000000000..7371b6e43 --- /dev/null +++ b/packages/plugins/workspace-worktree/src/__tests__/index.test.ts @@ -0,0 +1,771 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ProjectConfig, WorkspaceCreateConfig, WorkspaceInfo } from "@agent-orchestrator/core"; + +// --------------------------------------------------------------------------- +// Mocks — must be declared before any import that uses the mocked modules +// --------------------------------------------------------------------------- + +vi.mock("node:child_process", () => { + const mockExecFile = vi.fn(); + // Set custom promisify so `promisify(execFile)` returns { stdout, stderr } + (mockExecFile as any)[Symbol.for("nodejs.util.promisify.custom")] = vi.fn(); + return { execFile: mockExecFile }; +}); + +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + lstatSync: vi.fn(), + symlinkSync: vi.fn(), + rmSync: vi.fn(), + mkdirSync: vi.fn(), + readdirSync: vi.fn(), +})); + +vi.mock("node:os", () => ({ + homedir: () => "/mock-home", +})); + +// --------------------------------------------------------------------------- +// Imports (after mocks) +// --------------------------------------------------------------------------- + +import * as childProcess from "node:child_process"; +import { existsSync, lstatSync, symlinkSync, rmSync, mkdirSync, readdirSync } from "node:fs"; +import { create, manifest } from "../index.js"; + +// --------------------------------------------------------------------------- +// Typed mock references +// --------------------------------------------------------------------------- + +const mockExecFileAsync = (childProcess.execFile as any)[ + Symbol.for("nodejs.util.promisify.custom") +] as ReturnType; + +const mockExistsSync = existsSync as ReturnType; +const mockLstatSync = lstatSync as ReturnType; +const mockSymlinkSync = symlinkSync as ReturnType; +const mockRmSync = rmSync as ReturnType; +const mockMkdirSync = mkdirSync as ReturnType; +const mockReaddirSync = readdirSync as ReturnType; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function mockGitSuccess(stdout: string) { + mockExecFileAsync.mockResolvedValueOnce({ stdout: stdout + "\n", stderr: "" }); +} + +function mockGitError(message: string) { + mockExecFileAsync.mockRejectedValueOnce(new Error(message)); +} + +function makeProject(overrides?: Partial): ProjectConfig { + return { + name: "test-project", + repo: "test/repo", + path: "/repo/path", + defaultBranch: "main", + sessionPrefix: "test", + ...overrides, + }; +} + +function makeCreateConfig(overrides?: Partial): WorkspaceCreateConfig { + return { + projectId: "myproject", + project: makeProject(), + sessionId: "session-1", + branch: "feat/TEST-1", + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Reset mocks before each test +// --------------------------------------------------------------------------- + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// =========================================================================== +// Tests +// =========================================================================== + +describe("manifest", () => { + it("has name 'worktree' and slot 'workspace'", () => { + expect(manifest.name).toBe("worktree"); + expect(manifest.slot).toBe("workspace"); + expect(manifest.version).toBe("0.1.0"); + expect(manifest.description).toBe("Workspace plugin: git worktrees"); + }); +}); + +describe("create() factory", () => { + it("uses ~/.worktrees as default base dir", async () => { + const ws = create(); + + // Mock: fetch, worktree add + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1"); + }); + + it("uses custom worktreeDir from config", async () => { + const ws = create({ worktreeDir: "/custom/worktrees" }); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/custom/worktrees/myproject/session-1"); + }); + + it("expands tilde in custom worktreeDir", async () => { + const ws = create({ worktreeDir: "~/custom-path" }); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/custom-path/myproject/session-1"); + }); +}); + +describe("workspace.create()", () => { + it("calls git fetch and git worktree add with correct args", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create(makeCreateConfig()); + + // First call: git fetch origin --quiet + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["fetch", "origin", "--quiet"], + { cwd: "/repo/path" }, + ); + + // Second call: git worktree add -b + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "-b", + "feat/TEST-1", + "/mock-home/.worktrees/myproject/session-1", + "origin/main", + ], + { cwd: "/repo/path" }, + ); + }); + + it("creates the project worktree directory", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create(makeCreateConfig()); + + expect(mockMkdirSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject", + { recursive: true }, + ); + }); + + it("continues when fetch fails (offline)", async () => { + const ws = create(); + + mockGitError("Could not resolve host"); // fetch fails + mockGitSuccess(""); // worktree add succeeds + + const info = await ws.create(makeCreateConfig()); + + expect(info.path).toBe("/mock-home/.worktrees/myproject/session-1"); + }); + + it("handles branch already exists by adding worktree then checking out", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitSuccess(""); // checkout + + const info = await ws.create(makeCreateConfig()); + + // Third call: worktree add without -b + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "add", + "/mock-home/.worktrees/myproject/session-1", + "origin/main", + ], + { cwd: "/repo/path" }, + ); + + // Fourth call: checkout + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["checkout", "feat/TEST-1"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + + expect(info.branch).toBe("feat/TEST-1"); + }); + + it("cleans up worktree on checkout failure", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitError("checkout failed: conflict"); // checkout fails + mockGitSuccess(""); // worktree remove (cleanup) + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to checkout branch "feat/TEST-1" in worktree: checkout failed: conflict', + ); + + // Verify cleanup was attempted + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + [ + "worktree", + "remove", + "--force", + "/mock-home/.worktrees/myproject/session-1", + ], + { cwd: "/repo/path" }, + ); + }); + + it("still throws on checkout failure even if cleanup fails", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("already exists"); // worktree add -b fails + mockGitSuccess(""); // worktree add (without -b) + mockGitError("checkout failed"); // checkout fails + mockGitError("worktree remove failed"); // cleanup also fails + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to checkout branch "feat/TEST-1" in worktree', + ); + }); + + it("throws for non-already-exists worktree add errors", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitError("fatal: invalid reference"); // worktree add fails with other error + + await expect(ws.create(makeCreateConfig())).rejects.toThrow( + 'Failed to create worktree for branch "feat/TEST-1": fatal: invalid reference', + ); + }); + + it("rejects invalid projectId", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ projectId: "bad/project" })), + ).rejects.toThrow('Invalid projectId "bad/project"'); + }); + + it("rejects projectId with dots", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ projectId: "my.project" })), + ).rejects.toThrow('Invalid projectId "my.project"'); + }); + + it("rejects invalid sessionId", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ sessionId: "../escape" })), + ).rejects.toThrow('Invalid sessionId "../escape"'); + }); + + it("rejects sessionId with spaces", async () => { + const ws = create(); + + await expect( + ws.create(makeCreateConfig({ sessionId: "bad session" })), + ).rejects.toThrow('Invalid sessionId "bad session"'); + }); + + it("returns correct WorkspaceInfo", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + const info = await ws.create(makeCreateConfig()); + + expect(info).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + }); + + it("expands tilde in project path", async () => { + const ws = create(); + + mockGitSuccess(""); // fetch + mockGitSuccess(""); // worktree add + + await ws.create( + makeCreateConfig({ + project: makeProject({ path: "~/my-repo" }), + }), + ); + + // fetch should use expanded path + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["fetch", "origin", "--quiet"], + { cwd: "/mock-home/my-repo" }, + ); + }); +}); + +describe("workspace.destroy()", () => { + it("removes worktree via git commands", async () => { + const ws = create(); + + // rev-parse returns the .git dir + mockGitSuccess("/repo/path/.git"); + // worktree remove succeeds + mockGitSuccess(""); + + await ws.destroy("/mock-home/.worktrees/myproject/session-1"); + + // First call: rev-parse + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["rev-parse", "--path-format=absolute", "--git-common-dir"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + + // Second call: worktree remove + expect(mockExecFileAsync).toHaveBeenCalledWith( + "git", + ["worktree", "remove", "--force", "/mock-home/.worktrees/myproject/session-1"], + { cwd: "/repo/path" }, + ); + }); + + it("falls back to rmSync when git commands fail", async () => { + const ws = create(); + + mockGitError("not a git repository"); // rev-parse fails + mockExistsSync.mockReturnValueOnce(true); + + await ws.destroy("/mock-home/.worktrees/myproject/session-1"); + + expect(mockRmSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1", + { recursive: true, force: true }, + ); + }); + + it("does nothing if git fails and directory does not exist", async () => { + const ws = create(); + + mockGitError("not a git repository"); + mockExistsSync.mockReturnValueOnce(false); + + await ws.destroy("/nonexistent/path"); + + expect(mockRmSync).not.toHaveBeenCalled(); + }); +}); + +describe("workspace.list()", () => { + it("returns empty array when project directory does not exist", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(false); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("returns empty array when project directory has no subdirectories", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([]); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("parses worktree list porcelain output", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "branch refs/heads/feat/TEST-1", + "", + "worktree /mock-home/.worktrees/myproject/session-2", + "HEAD def5678", + "branch refs/heads/feat/TEST-2", + "", + "worktree /repo/path", + "HEAD 0000000", + "branch refs/heads/main", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }); + expect(result[1]).toEqual({ + path: "/mock-home/.worktrees/myproject/session-2", + branch: "feat/TEST-2", + sessionId: "session-2", + projectId: "myproject", + }); + }); + + it("handles detached HEAD worktrees", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "detached", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].branch).toBe("detached"); + }); + + it("excludes worktrees outside the project directory", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + const porcelainOutput = [ + "worktree /other/path/session-1", + "HEAD abc1234", + "branch refs/heads/feat/other", + "", + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD def5678", + "branch refs/heads/feat/TEST-1", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-1"); + }); + + it("returns empty when all git worktree list calls fail", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + ]); + + mockGitError("fatal: not a git repository"); + + const result = await ws.list("myproject"); + + expect(result).toEqual([]); + }); + + it("tries next directory when first worktree list fails", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: "session-2", isDirectory: () => true }, + ]); + + // First dir fails + mockGitError("fatal: not a git repository"); + // Second dir succeeds + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-2", + "HEAD abc1234", + "branch refs/heads/feat/TEST-2", + ].join("\n"); + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + expect(result[0].sessionId).toBe("session-2"); + }); + + it("rejects invalid projectId", async () => { + const ws = create(); + + await expect(ws.list("bad/id")).rejects.toThrow('Invalid projectId "bad/id"'); + }); + + it("filters out non-directory entries", async () => { + const ws = create(); + + mockExistsSync.mockReturnValueOnce(true); + mockReaddirSync.mockReturnValueOnce([ + { name: "session-1", isDirectory: () => true }, + { name: ".DS_Store", isDirectory: () => false }, + { name: "readme.txt", isDirectory: () => false }, + ]); + + const porcelainOutput = [ + "worktree /mock-home/.worktrees/myproject/session-1", + "HEAD abc1234", + "branch refs/heads/feat/TEST-1", + ].join("\n"); + + mockGitSuccess(porcelainOutput); + + const result = await ws.list("myproject"); + + expect(result).toHaveLength(1); + }); +}); + +describe("workspace.postCreate()", () => { + const workspaceInfo: WorkspaceInfo = { + path: "/mock-home/.worktrees/myproject/session-1", + branch: "feat/TEST-1", + sessionId: "session-1", + projectId: "myproject", + }; + + it("creates symlinks for configured paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["node_modules", ".env"] }); + + // First symlink: node_modules exists, target lstat throws (doesn't exist) + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + // Second symlink: .env exists, target lstat throws (doesn't exist) + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledTimes(2); + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/repo/path/node_modules", + "/mock-home/.worktrees/myproject/session-1/node_modules", + ); + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/repo/path/.env", + "/mock-home/.worktrees/myproject/session-1/.env", + ); + }); + + it("removes existing target before symlinking", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["node_modules"] }); + + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockReturnValueOnce({ + isSymbolicLink: () => true, + isFile: () => false, + isDirectory: () => false, + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockRmSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1/node_modules", + { recursive: true, force: true }, + ); + expect(mockSymlinkSync).toHaveBeenCalledTimes(1); + }); + + it("skips symlinks when source does not exist", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["nonexistent"] }); + + mockExistsSync.mockReturnValueOnce(false); // sourcePath does not exist + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).not.toHaveBeenCalled(); + }); + + it("rejects absolute symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["/absolute/path"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'Invalid symlink path "/absolute/path": must be a relative path without ".." segments', + ); + }); + + it("rejects .. directory traversal in symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["../escape"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'Invalid symlink path "../escape": must be a relative path without ".." segments', + ); + }); + + it("rejects .. embedded in symlink paths", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["foo/../../../etc/passwd"] }); + + await expect(ws.postCreate!(workspaceInfo, project)).rejects.toThrow( + 'must be a relative path without ".." segments', + ); + }); + + it("creates parent directories for nested symlink targets", async () => { + const ws = create(); + const project = makeProject({ symlinks: ["config/settings"] }); + + mockExistsSync.mockReturnValueOnce(true); // sourcePath exists + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockMkdirSync).toHaveBeenCalledWith( + "/mock-home/.worktrees/myproject/session-1/config", + { recursive: true }, + ); + }); + + it("runs postCreate commands", async () => { + const ws = create(); + const project = makeProject({ + postCreate: ["pnpm install", "pnpm build"], + }); + + // Two sh -c calls + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm install"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm build"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + }); + + it("does nothing when no symlinks or postCreate configured", async () => { + const ws = create(); + const project = makeProject(); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).not.toHaveBeenCalled(); + expect(mockExecFileAsync).not.toHaveBeenCalled(); + }); + + it("handles both symlinks and postCreate commands together", async () => { + const ws = create(); + const project = makeProject({ + symlinks: ["node_modules"], + postCreate: ["pnpm install"], + }); + + // Symlink: source exists, target doesn't + mockExistsSync.mockReturnValueOnce(true); + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + // postCreate command + mockExecFileAsync.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledTimes(1); + expect(mockExecFileAsync).toHaveBeenCalledWith( + "sh", + ["-c", "pnpm install"], + { cwd: "/mock-home/.worktrees/myproject/session-1" }, + ); + }); + + it("expands tilde in project path for symlink sources", async () => { + const ws = create(); + const project = makeProject({ path: "~/my-repo", symlinks: ["data"] }); + + mockExistsSync.mockReturnValueOnce(true); + mockLstatSync.mockImplementationOnce(() => { + throw new Error("ENOENT"); + }); + + await ws.postCreate!(workspaceInfo, project); + + expect(mockSymlinkSync).toHaveBeenCalledWith( + "/mock-home/my-repo/data", + "/mock-home/.worktrees/myproject/session-1/data", + ); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d134fa54..a59414777 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,18 @@ importers: '@agent-orchestrator/plugin-agent-opencode': specifier: workspace:* version: link:../plugins/agent-opencode + '@agent-orchestrator/plugin-runtime-process': + specifier: workspace:* + version: link:../plugins/runtime-process + '@agent-orchestrator/plugin-runtime-tmux': + specifier: workspace:* + version: link:../plugins/runtime-tmux + '@agent-orchestrator/plugin-workspace-clone': + specifier: workspace:* + version: link:../plugins/workspace-clone + '@agent-orchestrator/plugin-workspace-worktree': + specifier: workspace:* + version: link:../plugins/workspace-worktree devDependencies: '@types/node': specifier: ^25.2.3 @@ -214,6 +226,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/runtime-tmux: dependencies: @@ -227,6 +242,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/scm-github: dependencies: @@ -305,6 +323,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages/plugins/workspace-worktree: dependencies: @@ -318,6 +339,9 @@ importers: typescript: specifier: ^5.7.0 version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.2.3)(tsx@4.21.0)(yaml@2.8.2) packages/web: dependencies: