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 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-14 14:05:50 +05:30
parent 78bd420708
commit 16dba100eb
15 changed files with 3331 additions and 5 deletions

View File

@ -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<string, unknown>) => ({
name,
_config: config,
})),
};
}
function makeOrchestratorConfig(
overrides?: Partial<OrchestratorConfig>,
): 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<string, unknown> }>(
"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();
});
});

View File

@ -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",

View File

@ -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
});
});

View File

@ -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);
});
});

View File

@ -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<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout.trimEnd();
}
describe("workspace-clone (integration)", () => {
let repoDir: string;
let cloneBaseDir: string;
let workspace: ReturnType<typeof clonePlugin.create>;
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();
});
});

View File

@ -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<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout.trimEnd();
}
describe("workspace-worktree (integration)", () => {
let repoDir: string;
let worktreeBaseDir: string;
let workspace: ReturnType<typeof worktreePlugin.create>;
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();
});
});

View File

@ -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"
}
}

View File

@ -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<string, unknown> = {}) {
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<string, string> };
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);
});
});

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
/** 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",
});
});
});

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
/** 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>): 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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>)
.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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(fs.readdirSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
(fs.readdirSync as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(true);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
(fs.readdirSync as ReturnType<typeof vi.fn>).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();
});
});

View File

@ -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"
}
}

View File

@ -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<typeof vi.fn>;
const mockExistsSync = existsSync as ReturnType<typeof vi.fn>;
const mockLstatSync = lstatSync as ReturnType<typeof vi.fn>;
const mockSymlinkSync = symlinkSync as ReturnType<typeof vi.fn>;
const mockRmSync = rmSync as ReturnType<typeof vi.fn>;
const mockMkdirSync = mkdirSync as ReturnType<typeof vi.fn>;
const mockReaddirSync = readdirSync as ReturnType<typeof vi.fn>;
// ---------------------------------------------------------------------------
// 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>): ProjectConfig {
return {
name: "test-project",
repo: "test/repo",
path: "/repo/path",
defaultBranch: "main",
sessionPrefix: "test",
...overrides,
};
}
function makeCreateConfig(overrides?: Partial<WorkspaceCreateConfig>): 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 <branch> <path> <baseRef>
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",
);
});
});

View File

@ -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: