test: add comprehensive unit tests for all core services
Add vitest test suite with 103 tests across 5 test files: - metadata.test.ts (19 tests): read/write/update/delete/list, key=value parsing, comments, values with equals signs, archiving, dotfile exclusion - event-bus.test.ts (22 tests): emit/on/off, wildcard listeners, handler error resilience, priority inference, JSONL persistence round-trip, filtered history with sessionId/projectId/type/priority/since/limit - tmux.test.ts (23 tests): all tmux wrappers with mocked child_process, session listing/parsing, send-keys short vs load-buffer long text, capture-pane, kill, getPaneTTY - session-manager.test.ts (23 tests): spawn pipeline with mocked plugins, session numbering, branch derivation, event emission, metadata writing, list with dead runtime detection, kill with cleanup, send via runtime - lifecycle-manager.test.ts (16 tests): state transitions (spawning→working, killed, stuck, needs_input, ci_failed, merged, mergeable), reaction triggering, auto=false suppression, on/off handlers, getStates isolation Also fixes inferPriority to handle merge.completed as "action" priority. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de86054b9d
commit
2b9a28a031
|
|
@ -18,6 +18,8 @@
|
|||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -26,6 +28,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"typescript": "^5.7.0"
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,288 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createEventBus, createEvent } from "../event-bus.js";
|
||||
import type { OrchestratorEvent, EventType } from "../types.js";
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = join(tmpdir(), `ao-test-eventbus-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createEvent", () => {
|
||||
it("creates event with all fields", () => {
|
||||
const event = createEvent("session.spawned", {
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
message: "Session spawned",
|
||||
data: { branch: "main" },
|
||||
});
|
||||
|
||||
expect(event.id).toBeTruthy();
|
||||
expect(event.type).toBe("session.spawned");
|
||||
expect(event.sessionId).toBe("app-1");
|
||||
expect(event.projectId).toBe("my-app");
|
||||
expect(event.message).toBe("Session spawned");
|
||||
expect(event.data).toEqual({ branch: "main" });
|
||||
expect(event.timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("infers priority from event type", () => {
|
||||
expect(createEvent("session.stuck", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("session.needs_input", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("session.errored", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("urgent");
|
||||
expect(createEvent("review.approved", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("merge.ready", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("merge.completed", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("action");
|
||||
expect(createEvent("ci.failing", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
|
||||
expect(createEvent("review.changes_requested", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("warning");
|
||||
expect(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
|
||||
expect(createEvent("pr.created", { sessionId: "a", projectId: "p", message: "" }).priority).toBe("info");
|
||||
});
|
||||
|
||||
it("allows explicit priority override", () => {
|
||||
const event = createEvent("session.spawned", {
|
||||
sessionId: "a",
|
||||
projectId: "p",
|
||||
message: "",
|
||||
priority: "urgent",
|
||||
});
|
||||
expect(event.priority).toBe("urgent");
|
||||
});
|
||||
|
||||
it("generates unique IDs", () => {
|
||||
const e1 = createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" });
|
||||
const e2 = createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" });
|
||||
expect(e1.id).not.toBe(e2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEventBus (no persistence)", () => {
|
||||
it("emits events to typed handlers", () => {
|
||||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
bus.on("session.spawned", (e) => received.push(e));
|
||||
|
||||
const event = createEvent("session.spawned", {
|
||||
sessionId: "app-1",
|
||||
projectId: "p",
|
||||
message: "spawned",
|
||||
});
|
||||
bus.emit(event);
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].sessionId).toBe("app-1");
|
||||
});
|
||||
|
||||
it("emits events to wildcard handlers", () => {
|
||||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
bus.on("*", (e) => received.push(e));
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
|
||||
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not deliver events to wrong type", () => {
|
||||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
bus.on("ci.failing", (e) => received.push(e));
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("unsubscribes handlers with off()", () => {
|
||||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
const handler = (e: OrchestratorEvent) => received.push(e);
|
||||
|
||||
bus.on("session.spawned", handler);
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
expect(received).toHaveLength(1);
|
||||
|
||||
bus.off("session.spawned", handler);
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p", message: "" }));
|
||||
expect(received).toHaveLength(1); // still 1, not 2
|
||||
});
|
||||
|
||||
it("survives handler errors without breaking", () => {
|
||||
const bus = createEventBus(null);
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
bus.on("session.spawned", () => { throw new Error("boom"); });
|
||||
bus.on("session.spawned", (e) => received.push(e));
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("supports multiple handlers on same event", () => {
|
||||
const bus = createEventBus(null);
|
||||
let count = 0;
|
||||
|
||||
bus.on("session.spawned", () => count++);
|
||||
bus.on("session.spawned", () => count++);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHistory", () => {
|
||||
it("returns all events when no filter", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
|
||||
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p2", message: "" }));
|
||||
|
||||
const history = bus.getHistory();
|
||||
expect(history).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by sessionId", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p", message: "" }));
|
||||
|
||||
expect(bus.getHistory({ sessionId: "a" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by projectId", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p2", message: "" }));
|
||||
|
||||
expect(bus.getHistory({ projectId: "p1" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by type", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
|
||||
|
||||
expect(bus.getHistory({ type: "ci.failing" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by priority", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
bus.emit(createEvent("session.stuck", { sessionId: "b", projectId: "p", message: "" }));
|
||||
|
||||
expect(bus.getHistory({ priority: "urgent" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by since", () => {
|
||||
const bus = createEventBus(null);
|
||||
const beforeTime = new Date(Date.now() - 1000);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
||||
const afterTime = new Date(Date.now() + 1000);
|
||||
expect(bus.getHistory({ since: beforeTime })).toHaveLength(1);
|
||||
expect(bus.getHistory({ since: afterTime })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("applies limit (takes last N)", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
bus.emit(createEvent("session.spawned", { sessionId: `s-${i}`, projectId: "p", message: `msg-${i}` }));
|
||||
}
|
||||
|
||||
const result = bus.getHistory({ limit: 3 });
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].sessionId).toBe("s-7");
|
||||
expect(result[2].sessionId).toBe("s-9");
|
||||
});
|
||||
|
||||
it("combines multiple filters", () => {
|
||||
const bus = createEventBus(null);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p1", message: "" }));
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "b", projectId: "p1", message: "" }));
|
||||
bus.emit(createEvent("ci.failing", { sessionId: "a", projectId: "p1", message: "" }));
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p2", message: "" }));
|
||||
|
||||
expect(bus.getHistory({ sessionId: "a", projectId: "p1" })).toHaveLength(2);
|
||||
expect(bus.getHistory({ sessionId: "a", type: "session.spawned" })).toHaveLength(2);
|
||||
expect(bus.getHistory({ sessionId: "a", projectId: "p1", type: "session.spawned" })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("JSONL persistence", () => {
|
||||
it("persists events to JSONL file", () => {
|
||||
const logPath = join(tmpDir, "events.jsonl");
|
||||
const bus = createEventBus(logPath);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "hello" }));
|
||||
bus.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "oops" }));
|
||||
|
||||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
|
||||
const parsed = JSON.parse(lines[0]);
|
||||
expect(parsed.type).toBe("session.spawned");
|
||||
expect(parsed.sessionId).toBe("a");
|
||||
expect(parsed.timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads history from existing JSONL file on creation", () => {
|
||||
const logPath = join(tmpDir, "existing.jsonl");
|
||||
const event = createEvent("session.spawned", { sessionId: "old", projectId: "p", message: "old event" });
|
||||
const serialized = JSON.stringify({ ...event, timestamp: event.timestamp.toISOString() });
|
||||
require("node:fs").writeFileSync(logPath, serialized + "\n", "utf-8");
|
||||
|
||||
const bus = createEventBus(logPath);
|
||||
const history = bus.getHistory();
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0].sessionId).toBe("old");
|
||||
expect(history[0].timestamp).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("creates log directory if it does not exist", () => {
|
||||
const logPath = join(tmpDir, "subdir", "nested", "events.jsonl");
|
||||
const bus = createEventBus(logPath);
|
||||
|
||||
bus.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
||||
expect(existsSync(logPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("appends to existing log file", () => {
|
||||
const logPath = join(tmpDir, "append.jsonl");
|
||||
|
||||
const bus1 = createEventBus(logPath);
|
||||
bus1.emit(createEvent("session.spawned", { sessionId: "a", projectId: "p", message: "" }));
|
||||
|
||||
const bus2 = createEventBus(logPath);
|
||||
bus2.emit(createEvent("ci.failing", { sessionId: "b", projectId: "p", message: "" }));
|
||||
|
||||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
expect(lines).toHaveLength(2);
|
||||
|
||||
// bus2 should have loaded the first event + emitted the second
|
||||
expect(bus2.getHistory()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,702 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createLifecycleManager } from "../lifecycle-manager.js";
|
||||
import { createEventBus } from "../event-bus.js";
|
||||
import { writeMetadata, readMetadataRaw } from "../metadata.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
SessionManager,
|
||||
Session,
|
||||
Runtime,
|
||||
Agent,
|
||||
SCM,
|
||||
Notifier,
|
||||
EventBus,
|
||||
OrchestratorEvent,
|
||||
ActivityState,
|
||||
PRInfo,
|
||||
} from "../types.js";
|
||||
|
||||
let dataDir: string;
|
||||
let eventBus: EventBus;
|
||||
let mockSessionManager: SessionManager;
|
||||
let mockRuntime: Runtime;
|
||||
let mockAgent: Agent;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
function makeSession(overrides: Partial<Session> = {}): Session {
|
||||
return {
|
||||
id: "app-1",
|
||||
projectId: "my-app",
|
||||
status: "spawning",
|
||||
activity: "active",
|
||||
branch: "feat/test",
|
||||
issueId: null,
|
||||
pr: null,
|
||||
workspacePath: "/tmp/ws",
|
||||
runtimeHandle: { id: "rt-1", runtimeName: "mock", data: {} },
|
||||
agentInfo: null,
|
||||
createdAt: new Date(),
|
||||
lastActivityAt: new Date(),
|
||||
metadata: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makePR(overrides: Partial<PRInfo> = {}): PRInfo {
|
||||
return {
|
||||
number: 42,
|
||||
url: "https://github.com/org/repo/pull/42",
|
||||
title: "Fix things",
|
||||
owner: "org",
|
||||
repo: "repo",
|
||||
branch: "feat/test",
|
||||
baseBranch: "main",
|
||||
isDraft: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-lifecycle-${randomUUID()}`);
|
||||
mkdirSync(join(dataDir, "sessions"), { recursive: true });
|
||||
|
||||
eventBus = createEventBus(null);
|
||||
|
||||
mockRuntime = {
|
||||
name: "mock",
|
||||
create: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
getOutput: vi.fn(),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
mockAgent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock",
|
||||
getLaunchCommand: vi.fn(),
|
||||
getEnvironment: vi.fn(),
|
||||
detectActivity: vi.fn().mockResolvedValue("active" as ActivityState),
|
||||
isProcessRunning: vi.fn(),
|
||||
introspect: vi.fn(),
|
||||
};
|
||||
|
||||
mockRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn(),
|
||||
loadFromConfig: vi.fn(),
|
||||
};
|
||||
|
||||
mockSessionManager = {
|
||||
spawn: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
kill: vi.fn().mockResolvedValue(undefined),
|
||||
cleanup: vi.fn(),
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
config = {
|
||||
dataDir,
|
||||
worktreeDir: "/tmp/worktrees",
|
||||
port: 3000,
|
||||
defaults: {
|
||||
runtime: "mock",
|
||||
agent: "mock-agent",
|
||||
workspace: "mock-ws",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("start / stop", () => {
|
||||
it("starts and stops the polling loop", () => {
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
lm.start(60_000);
|
||||
// Should not throw on double start
|
||||
lm.start(60_000);
|
||||
lm.stop();
|
||||
// Should not throw on double stop
|
||||
lm.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("check (single session)", () => {
|
||||
it("detects transition from spawning to working", async () => {
|
||||
const session = makeSession({ status: "spawning" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
// Write metadata so updateMetadata works
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
eventBus.on("session.working", (e) => received.push(e));
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("working");
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].type).toBe("session.working");
|
||||
|
||||
// Metadata should be updated
|
||||
const meta = readMetadataRaw(dataDir, "app-1");
|
||||
expect(meta!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("detects killed state when runtime is dead", async () => {
|
||||
vi.mocked(mockRuntime.isAlive).mockResolvedValue(false);
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("killed");
|
||||
});
|
||||
|
||||
it("detects stuck state from agent", async () => {
|
||||
vi.mocked(mockAgent.detectActivity).mockResolvedValue("blocked");
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("stuck");
|
||||
});
|
||||
|
||||
it("detects needs_input from agent", async () => {
|
||||
vi.mocked(mockAgent.detectActivity).mockResolvedValue("waiting_input");
|
||||
|
||||
const session = makeSession({ status: "working" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("needs_input");
|
||||
});
|
||||
|
||||
it("detects PR states from SCM", async () => {
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn().mockResolvedValue("failing"),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("none"),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithSCM,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("ci_failed");
|
||||
});
|
||||
|
||||
it("detects merged PR", async () => {
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn(),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const session = makeSession({ status: "approved", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "approved",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithSCM,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("merged");
|
||||
});
|
||||
|
||||
it("detects mergeable when approved + CI green", async () => {
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn().mockResolvedValue("passing"),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("approved"),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn().mockResolvedValue({
|
||||
mergeable: true,
|
||||
ciPassing: true,
|
||||
approved: true,
|
||||
noConflicts: true,
|
||||
blockers: [],
|
||||
}),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithSCM,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(lm.getStates().get("app-1")).toBe("mergeable");
|
||||
});
|
||||
|
||||
it("throws for nonexistent session", async () => {
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(null);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await expect(lm.check("nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("does not emit event when status unchanged", async () => {
|
||||
const session = makeSession({ status: "working" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
eventBus.on("*", (e) => received.push(e));
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
// First check: spawning (from states map, empty) → working = transition
|
||||
// But the session's status is "working" and determineStatus returns "working"
|
||||
// Since states map is empty, oldStatus = session.status = "working", newStatus = "working"
|
||||
// So no transition
|
||||
// We need to seed the states map first
|
||||
// Actually, let's check twice: first time sets it, second time same = no event
|
||||
await lm.check("app-1");
|
||||
const eventsAfterFirst = [...received];
|
||||
|
||||
await lm.check("app-1");
|
||||
// No new events emitted since status didn't change
|
||||
expect(received.length).toBe(eventsAfterFirst.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactions", () => {
|
||||
it("triggers send-to-agent reaction on CI failure", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
auto: true,
|
||||
action: "send-to-agent",
|
||||
message: "CI is failing. Fix it.",
|
||||
retries: 2,
|
||||
escalateAfter: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn().mockResolvedValue("failing"),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("none"),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithSCM,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(mockSessionManager.send).toHaveBeenCalledWith("app-1", "CI is failing. Fix it.");
|
||||
});
|
||||
|
||||
it("does not trigger reaction when auto=false", async () => {
|
||||
config.reactions = {
|
||||
"ci-failed": {
|
||||
auto: false,
|
||||
action: "send-to-agent",
|
||||
message: "CI is failing.",
|
||||
},
|
||||
};
|
||||
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("open"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn().mockResolvedValue("failing"),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn().mockResolvedValue("none"),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const session = makeSession({ status: "pr_open", pr: makePR() });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: registryWithSCM,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(mockSessionManager.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("on / off event handlers", () => {
|
||||
it("subscribes and receives lifecycle events", async () => {
|
||||
const session = makeSession({ status: "spawning" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
lm.on("session.working", (e) => received.push(e));
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].type).toBe("session.working");
|
||||
});
|
||||
|
||||
it("unsubscribes handlers", async () => {
|
||||
const session = makeSession({ status: "spawning" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
const handler = (e: OrchestratorEvent) => received.push(e);
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
lm.on("session.working", handler);
|
||||
lm.off("session.working", handler);
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("wildcard handler receives all events", async () => {
|
||||
const session = makeSession({ status: "spawning" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
lm.on("*", (e) => received.push(e));
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
expect(received.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getStates", () => {
|
||||
it("returns copy of states map", async () => {
|
||||
const session = makeSession({ status: "spawning" });
|
||||
vi.mocked(mockSessionManager.get).mockResolvedValue(session);
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const lm = createLifecycleManager({
|
||||
config,
|
||||
registry: mockRegistry,
|
||||
sessionManager: mockSessionManager,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
await lm.check("app-1");
|
||||
|
||||
const states = lm.getStates();
|
||||
expect(states.get("app-1")).toBe("working");
|
||||
|
||||
// Modifying returned map shouldn't affect internal state
|
||||
states.set("app-1", "killed");
|
||||
expect(lm.getStates().get("app-1")).toBe("working");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdirSync, rmSync, readFileSync, existsSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
readMetadata,
|
||||
readMetadataRaw,
|
||||
writeMetadata,
|
||||
updateMetadata,
|
||||
deleteMetadata,
|
||||
listMetadata,
|
||||
} from "../metadata.js";
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-metadata-${randomUUID()}`);
|
||||
mkdirSync(join(dataDir, "sessions"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("writeMetadata + readMetadata", () => {
|
||||
it("writes and reads basic metadata", () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/worktree",
|
||||
branch: "feat/test",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
const meta = readMetadata(dataDir, "app-1");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.worktree).toBe("/tmp/worktree");
|
||||
expect(meta!.branch).toBe("feat/test");
|
||||
expect(meta!.status).toBe("working");
|
||||
});
|
||||
|
||||
it("writes and reads optional fields", () => {
|
||||
writeMetadata(dataDir, "app-2", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
issue: "https://linear.app/team/issue/INT-100",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
summary: "Implementing feature X",
|
||||
project: "my-app",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
runtimeHandle: '{"id":"tmux-1","runtimeName":"tmux"}',
|
||||
});
|
||||
|
||||
const meta = readMetadata(dataDir, "app-2");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.issue).toBe("https://linear.app/team/issue/INT-100");
|
||||
expect(meta!.pr).toBe("https://github.com/org/repo/pull/42");
|
||||
expect(meta!.summary).toBe("Implementing feature X");
|
||||
expect(meta!.project).toBe("my-app");
|
||||
expect(meta!.createdAt).toBe("2025-01-01T00:00:00.000Z");
|
||||
expect(meta!.runtimeHandle).toBe('{"id":"tmux-1","runtimeName":"tmux"}');
|
||||
});
|
||||
|
||||
it("returns null for nonexistent session", () => {
|
||||
const meta = readMetadata(dataDir, "nonexistent");
|
||||
expect(meta).toBeNull();
|
||||
});
|
||||
|
||||
it("produces key=value format matching bash scripts", () => {
|
||||
writeMetadata(dataDir, "app-3", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "feat/INT-123",
|
||||
status: "working",
|
||||
issue: "https://linear.app/team/issue/INT-123",
|
||||
});
|
||||
|
||||
const content = readFileSync(join(dataDir, "sessions", "app-3"), "utf-8");
|
||||
expect(content).toContain("worktree=/tmp/w\n");
|
||||
expect(content).toContain("branch=feat/INT-123\n");
|
||||
expect(content).toContain("status=working\n");
|
||||
expect(content).toContain("issue=https://linear.app/team/issue/INT-123\n");
|
||||
});
|
||||
|
||||
it("omits optional fields that are undefined", () => {
|
||||
writeMetadata(dataDir, "app-4", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
});
|
||||
|
||||
const content = readFileSync(join(dataDir, "sessions", "app-4"), "utf-8");
|
||||
expect(content).not.toContain("issue=");
|
||||
expect(content).not.toContain("pr=");
|
||||
expect(content).not.toContain("summary=");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readMetadataRaw", () => {
|
||||
it("reads arbitrary key=value pairs", () => {
|
||||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-1"),
|
||||
"worktree=/tmp/w\nbranch=main\ncustom_key=custom_value\n",
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-1");
|
||||
expect(raw).not.toBeNull();
|
||||
expect(raw!["worktree"]).toBe("/tmp/w");
|
||||
expect(raw!["custom_key"]).toBe("custom_value");
|
||||
});
|
||||
|
||||
it("returns null for nonexistent session", () => {
|
||||
expect(readMetadataRaw(dataDir, "nope")).toBeNull();
|
||||
});
|
||||
|
||||
it("handles comments and empty lines", () => {
|
||||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-2"),
|
||||
"# This is a comment\n\nkey1=value1\n\n# Another comment\nkey2=value2\n",
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-2");
|
||||
expect(raw).toEqual({ key1: "value1", key2: "value2" });
|
||||
});
|
||||
|
||||
it("handles values containing equals signs", () => {
|
||||
writeFileSync(
|
||||
join(dataDir, "sessions", "raw-3"),
|
||||
'runtimeHandle={"id":"foo","data":{"key":"val"}}\n',
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "raw-3");
|
||||
expect(raw!["runtimeHandle"]).toBe('{"id":"foo","data":{"key":"val"}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMetadata", () => {
|
||||
it("updates specific fields while preserving others", () => {
|
||||
writeMetadata(dataDir, "upd-1", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "spawning",
|
||||
});
|
||||
|
||||
updateMetadata(dataDir, "upd-1", { status: "working", pr: "https://github.com/org/repo/pull/1" });
|
||||
|
||||
const meta = readMetadata(dataDir, "upd-1");
|
||||
expect(meta!.status).toBe("working");
|
||||
expect(meta!.pr).toBe("https://github.com/org/repo/pull/1");
|
||||
expect(meta!.worktree).toBe("/tmp/w");
|
||||
expect(meta!.branch).toBe("main");
|
||||
});
|
||||
|
||||
it("deletes keys set to empty string", () => {
|
||||
writeMetadata(dataDir, "upd-2", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
summary: "doing stuff",
|
||||
});
|
||||
|
||||
updateMetadata(dataDir, "upd-2", { summary: "" });
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "upd-2");
|
||||
expect(raw!["summary"]).toBeUndefined();
|
||||
expect(raw!["status"]).toBe("working");
|
||||
});
|
||||
|
||||
it("creates file if it does not exist", () => {
|
||||
updateMetadata(dataDir, "upd-3", { status: "new", branch: "test" });
|
||||
|
||||
const raw = readMetadataRaw(dataDir, "upd-3");
|
||||
expect(raw).toEqual({ status: "new", branch: "test" });
|
||||
});
|
||||
|
||||
it("ignores undefined values", () => {
|
||||
writeMetadata(dataDir, "upd-4", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
updateMetadata(dataDir, "upd-4", { status: "pr_open", summary: undefined });
|
||||
|
||||
const meta = readMetadata(dataDir, "upd-4");
|
||||
expect(meta!.status).toBe("pr_open");
|
||||
expect(meta!.summary).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMetadata", () => {
|
||||
it("deletes metadata file and archives it", () => {
|
||||
writeMetadata(dataDir, "del-1", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
deleteMetadata(dataDir, "del-1", true);
|
||||
|
||||
expect(existsSync(join(dataDir, "sessions", "del-1"))).toBe(false);
|
||||
const archiveDir = join(dataDir, "sessions", "archive");
|
||||
expect(existsSync(archiveDir)).toBe(true);
|
||||
const files = require("node:fs").readdirSync(archiveDir) as string[];
|
||||
expect(files.length).toBe(1);
|
||||
expect(files[0]).toMatch(/^del-1_/);
|
||||
});
|
||||
|
||||
it("deletes without archiving when archive=false", () => {
|
||||
writeMetadata(dataDir, "del-2", {
|
||||
worktree: "/tmp/w",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
});
|
||||
|
||||
deleteMetadata(dataDir, "del-2", false);
|
||||
|
||||
expect(existsSync(join(dataDir, "sessions", "del-2"))).toBe(false);
|
||||
expect(existsSync(join(dataDir, "sessions", "archive"))).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op for nonexistent session", () => {
|
||||
expect(() => deleteMetadata(dataDir, "nope")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("listMetadata", () => {
|
||||
it("lists all session IDs", () => {
|
||||
writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" });
|
||||
writeMetadata(dataDir, "app-2", { worktree: "/tmp", branch: "b", status: "s" });
|
||||
writeMetadata(dataDir, "app-3", { worktree: "/tmp", branch: "c", status: "s" });
|
||||
|
||||
const list = listMetadata(dataDir);
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list.sort()).toEqual(["app-1", "app-2", "app-3"]);
|
||||
});
|
||||
|
||||
it("excludes archive directory and dotfiles", () => {
|
||||
writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" });
|
||||
mkdirSync(join(dataDir, "sessions", "archive"), { recursive: true });
|
||||
writeFileSync(join(dataDir, "sessions", ".hidden"), "x", "utf-8");
|
||||
|
||||
const list = listMetadata(dataDir);
|
||||
expect(list).toEqual(["app-1"]);
|
||||
});
|
||||
|
||||
it("returns empty array when sessions dir does not exist", () => {
|
||||
const emptyDir = join(tmpdir(), `ao-test-empty-${randomUUID()}`);
|
||||
const list = listMetadata(emptyDir);
|
||||
expect(list).toEqual([]);
|
||||
// no cleanup needed since dir was never created
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,521 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createSessionManager } from "../session-manager.js";
|
||||
import { createEventBus } from "../event-bus.js";
|
||||
import { writeMetadata, readMetadata, listMetadata } from "../metadata.js";
|
||||
import type {
|
||||
OrchestratorConfig,
|
||||
PluginRegistry,
|
||||
Runtime,
|
||||
Agent,
|
||||
Workspace,
|
||||
Tracker,
|
||||
SCM,
|
||||
RuntimeHandle,
|
||||
EventBus,
|
||||
OrchestratorEvent,
|
||||
} from "../types.js";
|
||||
|
||||
let dataDir: string;
|
||||
let eventBus: EventBus;
|
||||
let mockRuntime: Runtime;
|
||||
let mockAgent: Agent;
|
||||
let mockWorkspace: Workspace;
|
||||
let mockRegistry: PluginRegistry;
|
||||
let config: OrchestratorConfig;
|
||||
|
||||
function makeHandle(id: string): RuntimeHandle {
|
||||
return { id, runtimeName: "mock", data: {} };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dataDir = join(tmpdir(), `ao-test-session-mgr-${randomUUID()}`);
|
||||
mkdirSync(join(dataDir, "sessions"), { recursive: true });
|
||||
|
||||
eventBus = createEventBus(null);
|
||||
|
||||
mockRuntime = {
|
||||
name: "mock",
|
||||
create: vi.fn().mockResolvedValue(makeHandle("rt-1")),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
getOutput: vi.fn().mockResolvedValue(""),
|
||||
isAlive: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
|
||||
mockAgent = {
|
||||
name: "mock-agent",
|
||||
processName: "mock",
|
||||
getLaunchCommand: vi.fn().mockReturnValue("mock-agent --start"),
|
||||
getEnvironment: vi.fn().mockReturnValue({ AGENT_VAR: "1" }),
|
||||
detectActivity: vi.fn().mockResolvedValue("active"),
|
||||
isProcessRunning: vi.fn().mockResolvedValue(true),
|
||||
introspect: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
|
||||
mockWorkspace = {
|
||||
name: "mock-ws",
|
||||
create: vi.fn().mockResolvedValue({
|
||||
path: "/tmp/mock-ws/app-1",
|
||||
branch: "feat/TEST-1",
|
||||
sessionId: "app-1",
|
||||
projectId: "my-app",
|
||||
}),
|
||||
destroy: vi.fn().mockResolvedValue(undefined),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockRegistry = {
|
||||
register: vi.fn(),
|
||||
get: vi.fn().mockImplementation((slot: string, name: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
loadBuiltins: vi.fn().mockResolvedValue(undefined),
|
||||
loadFromConfig: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
config = {
|
||||
dataDir,
|
||||
worktreeDir: "/tmp/worktrees",
|
||||
port: 3000,
|
||||
defaults: {
|
||||
runtime: "mock",
|
||||
agent: "mock-agent",
|
||||
workspace: "mock-ws",
|
||||
notifiers: ["desktop"],
|
||||
},
|
||||
projects: {
|
||||
"my-app": {
|
||||
name: "My App",
|
||||
repo: "org/my-app",
|
||||
path: "/tmp/my-app",
|
||||
defaultBranch: "main",
|
||||
sessionPrefix: "app",
|
||||
scm: { plugin: "github" },
|
||||
tracker: { plugin: "github" },
|
||||
},
|
||||
},
|
||||
notifiers: {},
|
||||
notificationRouting: {
|
||||
urgent: ["desktop"],
|
||||
action: ["desktop"],
|
||||
warning: [],
|
||||
info: [],
|
||||
},
|
||||
reactions: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("spawn", () => {
|
||||
it("creates a session with workspace, runtime, and agent", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
|
||||
const session = await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(session.id).toBe("app-1");
|
||||
expect(session.status).toBe("spawning");
|
||||
expect(session.projectId).toBe("my-app");
|
||||
expect(session.runtimeHandle).toEqual(makeHandle("rt-1"));
|
||||
|
||||
// Verify workspace was created
|
||||
expect(mockWorkspace.create).toHaveBeenCalled();
|
||||
// Verify agent launch command was requested
|
||||
expect(mockAgent.getLaunchCommand).toHaveBeenCalled();
|
||||
// Verify runtime was created
|
||||
expect(mockRuntime.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses issue ID to derive branch name", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
|
||||
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
|
||||
|
||||
expect(session.branch).toBe("feat/INT-100");
|
||||
expect(session.issueId).toBe("INT-100");
|
||||
});
|
||||
|
||||
it("uses tracker.branchName when tracker is available", async () => {
|
||||
const mockTracker: Tracker = {
|
||||
name: "mock-tracker",
|
||||
getIssue: vi.fn().mockResolvedValue({}),
|
||||
isCompleted: vi.fn().mockResolvedValue(false),
|
||||
issueUrl: vi.fn().mockReturnValue(""),
|
||||
branchName: vi.fn().mockReturnValue("custom/INT-100-my-feature"),
|
||||
generatePrompt: vi.fn().mockResolvedValue(""),
|
||||
};
|
||||
|
||||
const registryWithTracker: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "tracker") return mockTracker;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({
|
||||
config,
|
||||
registry: registryWithTracker,
|
||||
eventBus,
|
||||
});
|
||||
|
||||
const session = await sm.spawn({ projectId: "my-app", issueId: "INT-100" });
|
||||
expect(session.branch).toBe("custom/INT-100-my-feature");
|
||||
});
|
||||
|
||||
it("increments session numbers correctly", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
|
||||
// Pre-create some metadata to simulate existing sessions
|
||||
writeMetadata(dataDir, "app-3", { worktree: "/tmp", branch: "b", status: "working" });
|
||||
writeMetadata(dataDir, "app-7", { worktree: "/tmp", branch: "b", status: "working" });
|
||||
|
||||
const session = await sm.spawn({ projectId: "my-app" });
|
||||
expect(session.id).toBe("app-8");
|
||||
});
|
||||
|
||||
it("emits session.spawned event", async () => {
|
||||
const received: OrchestratorEvent[] = [];
|
||||
eventBus.on("session.spawned", (e) => received.push(e));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.spawn({ projectId: "my-app" });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].type).toBe("session.spawned");
|
||||
expect(received[0].sessionId).toBe("app-1");
|
||||
});
|
||||
|
||||
it("writes metadata file", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.spawn({ projectId: "my-app", issueId: "INT-42" });
|
||||
|
||||
const meta = readMetadata(dataDir, "app-1");
|
||||
expect(meta).not.toBeNull();
|
||||
expect(meta!.status).toBe("spawning");
|
||||
expect(meta!.project).toBe("my-app");
|
||||
expect(meta!.issue).toBe("INT-42");
|
||||
});
|
||||
|
||||
it("throws for unknown project", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await expect(sm.spawn({ projectId: "nonexistent" })).rejects.toThrow("Unknown project");
|
||||
});
|
||||
|
||||
it("throws when runtime plugin is missing", async () => {
|
||||
const emptyRegistry: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
const sm = createSessionManager({ config, registry: emptyRegistry, eventBus });
|
||||
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("list", () => {
|
||||
it("lists sessions from metadata", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/w1",
|
||||
branch: "feat/a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
writeMetadata(dataDir, "app-2", {
|
||||
worktree: "/tmp/w2",
|
||||
branch: "feat/b",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
const sessions = await sm.list();
|
||||
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions.map((s) => s.id).sort()).toEqual(["app-1", "app-2"]);
|
||||
});
|
||||
|
||||
it("filters by project ID", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
writeMetadata(dataDir, "other-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "b",
|
||||
status: "working",
|
||||
project: "other",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
const sessions = await sm.list("my-app");
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].id).toBe("app-1");
|
||||
});
|
||||
|
||||
it("marks dead runtimes as killed", async () => {
|
||||
const deadRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
isAlive: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const registryWithDead: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return deadRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "a",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithDead, eventBus });
|
||||
const sessions = await sm.list();
|
||||
|
||||
expect(sessions[0].status).toBe("killed");
|
||||
expect(sessions[0].activity).toBe("exited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("returns session by ID", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
pr: "https://github.com/org/repo/pull/42",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
const session = await sm.get("app-1");
|
||||
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.id).toBe("app-1");
|
||||
expect(session!.pr).not.toBeNull();
|
||||
expect(session!.pr!.number).toBe(42);
|
||||
expect(session!.pr!.url).toBe("https://github.com/org/repo/pull/42");
|
||||
});
|
||||
|
||||
it("returns null for nonexistent session", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
expect(await sm.get("nonexistent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kill", () => {
|
||||
it("destroys runtime, workspace, and archives metadata", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp/ws",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.kill("app-1");
|
||||
|
||||
expect(mockRuntime.destroy).toHaveBeenCalledWith(makeHandle("rt-1"));
|
||||
expect(mockWorkspace.destroy).toHaveBeenCalledWith("/tmp/ws");
|
||||
expect(readMetadata(dataDir, "app-1")).toBeNull(); // archived + deleted
|
||||
});
|
||||
|
||||
it("emits session.killed event", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const received: OrchestratorEvent[] = [];
|
||||
eventBus.on("session.killed", (e) => received.push(e));
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.kill("app-1");
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].sessionId).toBe("app-1");
|
||||
});
|
||||
|
||||
it("throws for nonexistent session", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await expect(sm.kill("nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("tolerates runtime destroy failure", async () => {
|
||||
const failRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
destroy: vi.fn().mockRejectedValue(new Error("already gone")),
|
||||
};
|
||||
const registryWithFail: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return failRuntime;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithFail, eventBus });
|
||||
// Should not throw even though runtime.destroy fails
|
||||
await expect(sm.kill("app-1")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup", () => {
|
||||
it("kills sessions with merged PRs", async () => {
|
||||
const mockSCM: SCM = {
|
||||
name: "mock-scm",
|
||||
detectPR: vi.fn(),
|
||||
getPRState: vi.fn().mockResolvedValue("merged"),
|
||||
mergePR: vi.fn(),
|
||||
closePR: vi.fn(),
|
||||
getCIChecks: vi.fn(),
|
||||
getCISummary: vi.fn(),
|
||||
getReviews: vi.fn(),
|
||||
getReviewDecision: vi.fn(),
|
||||
getPendingComments: vi.fn(),
|
||||
getAutomatedComments: vi.fn(),
|
||||
getMergeability: vi.fn(),
|
||||
};
|
||||
|
||||
const registryWithSCM: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return mockRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
if (slot === "scm") return mockSCM;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "pr_open",
|
||||
project: "my-app",
|
||||
pr: "https://github.com/org/repo/pull/10",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithSCM, eventBus });
|
||||
const result = await sm.cleanup();
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("skips sessions without merged PRs or completed issues", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
const result = await sm.cleanup();
|
||||
|
||||
expect(result.killed).toHaveLength(0);
|
||||
expect(result.skipped).toContain("app-1");
|
||||
});
|
||||
|
||||
it("kills sessions with dead runtimes", async () => {
|
||||
const deadRuntime: Runtime = {
|
||||
...mockRuntime,
|
||||
isAlive: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const registryWithDead: PluginRegistry = {
|
||||
...mockRegistry,
|
||||
get: vi.fn().mockImplementation((slot: string) => {
|
||||
if (slot === "runtime") return deadRuntime;
|
||||
if (slot === "agent") return mockAgent;
|
||||
if (slot === "workspace") return mockWorkspace;
|
||||
return null;
|
||||
}),
|
||||
};
|
||||
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: registryWithDead, eventBus });
|
||||
const result = await sm.cleanup();
|
||||
|
||||
expect(result.killed).toContain("app-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("send", () => {
|
||||
it("sends message via runtime.sendMessage", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
runtimeHandle: JSON.stringify(makeHandle("rt-1")),
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await sm.send("app-1", "Fix the CI failures");
|
||||
|
||||
expect(mockRuntime.sendMessage).toHaveBeenCalledWith(
|
||||
makeHandle("rt-1"),
|
||||
"Fix the CI failures"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for nonexistent session", async () => {
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await expect(sm.send("nope", "hello")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws when no runtime handle", async () => {
|
||||
writeMetadata(dataDir, "app-1", {
|
||||
worktree: "/tmp",
|
||||
branch: "main",
|
||||
status: "working",
|
||||
project: "my-app",
|
||||
});
|
||||
|
||||
const sm = createSessionManager({ config, registry: mockRegistry, eventBus });
|
||||
await expect(sm.send("app-1", "hello")).rejects.toThrow("No runtime handle");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import * as childProcess from "node:child_process";
|
||||
import {
|
||||
isTmuxAvailable,
|
||||
listSessions,
|
||||
hasSession,
|
||||
newSession,
|
||||
sendKeys,
|
||||
capturePane,
|
||||
killSession,
|
||||
getPaneTTY,
|
||||
} from "../tmux.js";
|
||||
|
||||
// Mock child_process.execFile
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExecFile = vi.mocked(childProcess.execFile);
|
||||
|
||||
/** Helper to make execFile resolve with stdout. */
|
||||
function mockTmuxSuccess(stdout: string) {
|
||||
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
||||
(callback as Function)(null, stdout, "");
|
||||
return {} as any;
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper to make execFile reject with an error. */
|
||||
function mockTmuxError(message: string) {
|
||||
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
||||
(callback as Function)(new Error(message), "", message);
|
||||
return {} as any;
|
||||
});
|
||||
}
|
||||
|
||||
/** Helper for sequential tmux calls returning different results. */
|
||||
function mockTmuxSequence(results: Array<{ stdout?: string; error?: string }>) {
|
||||
let callIndex = 0;
|
||||
mockExecFile.mockImplementation((_cmd, _args, _opts, callback) => {
|
||||
const result = results[callIndex] ?? results[results.length - 1];
|
||||
callIndex++;
|
||||
if (result.error) {
|
||||
(callback as Function)(new Error(result.error), "", result.error);
|
||||
} else {
|
||||
(callback as Function)(null, result.stdout ?? "", "");
|
||||
}
|
||||
return {} as any;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("isTmuxAvailable", () => {
|
||||
it("returns true when tmux server is running", async () => {
|
||||
mockTmuxSuccess("session1\nsession2\n");
|
||||
expect(await isTmuxAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when tmux server is not running", async () => {
|
||||
mockTmuxError("no server running");
|
||||
expect(await isTmuxAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listSessions", () => {
|
||||
it("parses tmux session list", async () => {
|
||||
mockTmuxSuccess(
|
||||
"app-1\tMon Jan 1 00:00:00 2025\t0\t2\n" +
|
||||
"app-2\tTue Jan 2 00:00:00 2025\t1\t1\n"
|
||||
);
|
||||
|
||||
const sessions = await listSessions();
|
||||
expect(sessions).toHaveLength(2);
|
||||
expect(sessions[0]).toEqual({
|
||||
name: "app-1",
|
||||
created: "Mon Jan 1 00:00:00 2025",
|
||||
attached: false,
|
||||
windows: 2,
|
||||
});
|
||||
expect(sessions[1]).toEqual({
|
||||
name: "app-2",
|
||||
created: "Tue Jan 2 00:00:00 2025",
|
||||
attached: true,
|
||||
windows: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no sessions", async () => {
|
||||
mockTmuxError("no server running on /private/tmp/tmux-501/default");
|
||||
expect(await listSessions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles empty output", async () => {
|
||||
mockTmuxSuccess("");
|
||||
expect(await listSessions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSession", () => {
|
||||
it("returns true when session exists", async () => {
|
||||
mockTmuxSuccess("");
|
||||
expect(await hasSession("app-1")).toBe(true);
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["has-session", "-t", "app-1"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when session does not exist", async () => {
|
||||
mockTmuxError("session not found");
|
||||
expect(await hasSession("app-99")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("newSession", () => {
|
||||
it("creates a basic session", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({ name: "test-1", cwd: "/tmp/workspace" });
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["new-session", "-d", "-s", "test-1", "-c", "/tmp/workspace"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("includes environment variables", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({
|
||||
name: "test-2",
|
||||
cwd: "/tmp",
|
||||
environment: { AO_SESSION: "test-2", SOME_VAR: "value" },
|
||||
});
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("-e");
|
||||
expect(args).toContain("AO_SESSION=test-2");
|
||||
expect(args).toContain("SOME_VAR=value");
|
||||
});
|
||||
|
||||
it("includes window size", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await newSession({ name: "test-3", cwd: "/tmp", width: 200, height: 50 });
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("-x");
|
||||
expect(args).toContain("200");
|
||||
expect(args).toContain("-y");
|
||||
expect(args).toContain("50");
|
||||
});
|
||||
|
||||
it("sends initial command after creation", async () => {
|
||||
// First call: new-session, Second call: send-keys, Third call: send-keys Enter
|
||||
mockTmuxSequence([
|
||||
{ stdout: "" },
|
||||
{ stdout: "" },
|
||||
{ stdout: "" },
|
||||
]);
|
||||
|
||||
await newSession({ name: "test-4", cwd: "/tmp", command: "echo hello" });
|
||||
|
||||
// Should have called new-session then send-keys twice (text + Enter)
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(3);
|
||||
const secondCallArgs = mockExecFile.mock.calls[1][1] as string[];
|
||||
expect(secondCallArgs).toContain("send-keys");
|
||||
expect(secondCallArgs).toContain("echo hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendKeys", () => {
|
||||
it("sends short text with send-keys", async () => {
|
||||
mockTmuxSequence([{ stdout: "" }, { stdout: "" }]);
|
||||
|
||||
await sendKeys("app-1", "hello world");
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(2);
|
||||
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(firstArgs).toEqual(["send-keys", "-t", "app-1", "hello world"]);
|
||||
// Second call is Enter
|
||||
const secondArgs = mockExecFile.mock.calls[1][1] as string[];
|
||||
expect(secondArgs).toEqual(["send-keys", "-t", "app-1", "Enter"]);
|
||||
});
|
||||
|
||||
it("skips Enter when pressEnter=false", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await sendKeys("app-1", "hello", false);
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses load-buffer for long text", async () => {
|
||||
const longText = "a".repeat(250);
|
||||
mockTmuxSequence([
|
||||
{ stdout: "" }, // load-buffer
|
||||
{ stdout: "" }, // paste-buffer
|
||||
{ stdout: "" }, // send-keys Enter
|
||||
]);
|
||||
|
||||
await sendKeys("app-1", longText);
|
||||
|
||||
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(firstArgs[0]).toBe("load-buffer");
|
||||
|
||||
const secondArgs = mockExecFile.mock.calls[1][1] as string[];
|
||||
expect(secondArgs).toEqual(["paste-buffer", "-t", "app-1"]);
|
||||
});
|
||||
|
||||
it("uses load-buffer for multiline text", async () => {
|
||||
mockTmuxSequence([
|
||||
{ stdout: "" }, // load-buffer
|
||||
{ stdout: "" }, // paste-buffer
|
||||
{ stdout: "" }, // send-keys Enter
|
||||
]);
|
||||
|
||||
await sendKeys("app-1", "line1\nline2");
|
||||
|
||||
const firstArgs = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(firstArgs[0]).toBe("load-buffer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capturePane", () => {
|
||||
it("captures pane output with default lines", async () => {
|
||||
mockTmuxSuccess("some output\nfrom tmux\n");
|
||||
|
||||
const output = await capturePane("app-1");
|
||||
expect(output).toBe("some output\nfrom tmux\n");
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["capture-pane", "-t", "app-1", "-p", "-S", "-30"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("captures with custom line count", async () => {
|
||||
mockTmuxSuccess("output\n");
|
||||
|
||||
await capturePane("app-1", 50);
|
||||
|
||||
const args = mockExecFile.mock.calls[0][1] as string[];
|
||||
expect(args).toContain("-50");
|
||||
});
|
||||
});
|
||||
|
||||
describe("killSession", () => {
|
||||
it("kills a tmux session", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
await killSession("app-1");
|
||||
|
||||
expect(mockExecFile).toHaveBeenCalledWith(
|
||||
"tmux",
|
||||
["kill-session", "-t", "app-1"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when session does not exist", async () => {
|
||||
mockTmuxError("session not found: app-99");
|
||||
await expect(killSession("app-99")).rejects.toThrow("session not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPaneTTY", () => {
|
||||
it("returns TTY for first pane", async () => {
|
||||
mockTmuxSuccess("/dev/ttys004\n");
|
||||
|
||||
const tty = await getPaneTTY("app-1");
|
||||
expect(tty).toBe("/dev/ttys004");
|
||||
});
|
||||
|
||||
it("returns first TTY when multiple panes", async () => {
|
||||
mockTmuxSuccess("/dev/ttys004\n/dev/ttys005\n");
|
||||
|
||||
const tty = await getPaneTTY("app-1");
|
||||
expect(tty).toBe("/dev/ttys004");
|
||||
});
|
||||
|
||||
it("returns null when session not found", async () => {
|
||||
mockTmuxError("session not found");
|
||||
|
||||
const tty = await getPaneTTY("nonexistent");
|
||||
expect(tty).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty output", async () => {
|
||||
mockTmuxSuccess("");
|
||||
|
||||
const tty = await getPaneTTY("app-1");
|
||||
expect(tty).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -52,7 +52,7 @@ function inferPriority(type: EventType): EventPriority {
|
|||
if (type.includes("stuck") || type.includes("needs_input") || type.includes("errored")) {
|
||||
return "urgent";
|
||||
}
|
||||
if (type.includes("approved") || type.includes("ready") || type.includes("merged")) {
|
||||
if (type.includes("approved") || type.includes("ready") || type.includes("merged") || type.includes("completed")) {
|
||||
return "action";
|
||||
}
|
||||
if (type.includes("fail") || type.includes("changes_requested") || type.includes("conflicts")) {
|
||||
|
|
|
|||
1262
pnpm-lock.yaml
1262
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue