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:
parent
78bd420708
commit
16dba100eb
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue