289 lines
10 KiB
TypeScript
289 lines
10 KiB
TypeScript
/**
|
|
* Integration tests for the Claude Code agent plugin.
|
|
*
|
|
* Two test suites:
|
|
*
|
|
* 1. "path encoding & JSONL reading" — validates that toClaudeProjectPath()
|
|
* resolves to real ~/.claude/projects/ directories and readLastJsonlEntry()
|
|
* can parse real session files. Only requires Claude to have been run at
|
|
* least once on this machine (no API key, no tmux needed).
|
|
*
|
|
* 2. "agent-claude-code (integration)" — full lifecycle test spawning a real
|
|
* Claude process. Requires claude binary, tmux, and ANTHROPIC_API_KEY.
|
|
*
|
|
* Skipped automatically when prerequisites are missing.
|
|
*/
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { mkdtemp, readdir, realpath, rm } from "node:fs/promises";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
import {
|
|
readLastJsonlEntry,
|
|
type ActivityDetection,
|
|
type AgentSessionInfo,
|
|
} from "@composio/ao-core";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import claudeCodePlugin, { toClaudeProjectPath } from "@composio/ao-plugin-agent-claude-code";
|
|
import {
|
|
isTmuxAvailable,
|
|
killSessionsByPrefix,
|
|
createSession,
|
|
killSession,
|
|
} from "./helpers/tmux.js";
|
|
import { pollUntilEqual, sleep } from "./helpers/polling.js";
|
|
import { makeTmuxHandle, makeSession } from "./helpers/session-factory.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prerequisites
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SESSION_PREFIX = "ao-inttest-claude-";
|
|
|
|
async function findClaudeBinary(): Promise<string | null> {
|
|
for (const bin of ["claude"]) {
|
|
try {
|
|
await execFileAsync("which", [bin], { timeout: 5_000 });
|
|
return bin;
|
|
} catch {
|
|
// not found
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const tmuxOk = await isTmuxAvailable();
|
|
const claudeBin = await findClaudeBinary();
|
|
const hasApiKey = Boolean(process.env.ANTHROPIC_API_KEY);
|
|
const canRun = tmuxOk && claudeBin !== null && hasApiKey;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Path encoding & JSONL reading — real ~/.claude/projects/ validation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Find a workspace path on this machine that has a matching Claude project
|
|
* directory with at least one JSONL session file. Returns null if Claude
|
|
* has never been used (test will skip).
|
|
*/
|
|
async function findRealClaudeProject(): Promise<{
|
|
workspacePath: string;
|
|
projectDir: string;
|
|
jsonlFile: string;
|
|
} | null> {
|
|
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
let dirs: string[];
|
|
try {
|
|
dirs = await readdir(claudeProjectsDir);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
for (const dir of dirs) {
|
|
// Reverse the encoding: leading dash → leading slash, internal dashes → slashes
|
|
// This is a heuristic — we just need one workspace that exists on disk
|
|
const projectDir = join(claudeProjectsDir, dir);
|
|
let files: string[];
|
|
try {
|
|
files = await readdir(projectDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
|
|
if (jsonlFiles.length === 0) continue;
|
|
|
|
// Try to reconstruct the workspace path from the encoded dir name
|
|
// Encoded format: /Users/dev/project → -Users-dev-project
|
|
// We can't perfectly reverse this (dashes are ambiguous), but we can
|
|
// verify the forward direction: encode known paths and check if they match
|
|
const candidatePath = "/" + dir.slice(1).replace(/-/g, "/");
|
|
const reEncoded = toClaudeProjectPath(candidatePath);
|
|
if (reEncoded === dir) {
|
|
return {
|
|
workspacePath: candidatePath,
|
|
projectDir,
|
|
jsonlFile: join(projectDir, jsonlFiles[0]),
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const realProject = await findRealClaudeProject();
|
|
|
|
describe.skipIf(!realProject)("path encoding & JSONL reading (real Claude data)", () => {
|
|
it("toClaudeProjectPath resolves to a real ~/.claude/projects/ directory", () => {
|
|
const encoded = toClaudeProjectPath(realProject!.workspacePath);
|
|
const expectedDir = join(homedir(), ".claude", "projects", encoded);
|
|
expect(expectedDir).toBe(realProject!.projectDir);
|
|
});
|
|
|
|
it("readLastJsonlEntry parses a real session JSONL file", async () => {
|
|
const entry = await readLastJsonlEntry(realProject!.jsonlFile);
|
|
|
|
expect(entry).not.toBeNull();
|
|
expect(entry!.modifiedAt).toBeInstanceOf(Date);
|
|
// lastType should be a known Claude message type or null (not undefined)
|
|
expect(entry!.lastType === null || typeof entry!.lastType === "string").toBe(true);
|
|
});
|
|
|
|
it("readLastJsonlEntry returns a recognized message type", async () => {
|
|
const entry = await readLastJsonlEntry(realProject!.jsonlFile);
|
|
if (entry?.lastType) {
|
|
const knownTypes = [
|
|
"user",
|
|
"assistant",
|
|
"system",
|
|
"last-prompt",
|
|
"tool_use",
|
|
"progress",
|
|
"permission_request",
|
|
"error",
|
|
"summary",
|
|
"result",
|
|
"file-history-snapshot",
|
|
"queue-operation",
|
|
"pr-link",
|
|
];
|
|
expect(knownTypes).toContain(entry.lastType);
|
|
}
|
|
});
|
|
|
|
it("getActivityState returns a valid state for a real workspace path", async () => {
|
|
const agent = claudeCodePlugin.create();
|
|
// Mock isProcessRunning to return false — we're not testing process detection,
|
|
// we're testing path resolution and JSONL parsing
|
|
vi.spyOn(agent, "isProcessRunning").mockResolvedValue(false);
|
|
|
|
const handle = makeTmuxHandle("fake-session");
|
|
const session = makeSession("real-path-test", handle, realProject!.workspacePath);
|
|
const state = await agent.getActivityState(session);
|
|
|
|
// Process is "not running" so should get "exited" — but the important thing
|
|
// is it didn't return null (which would mean the path didn't resolve)
|
|
expect(state).not.toBeNull();
|
|
expect(state?.state ?? state).toBe("exited");
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Full lifecycle test (requires claude binary + API key + tmux)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe.skipIf(!canRun)("agent-claude-code (integration)", () => {
|
|
const agent = claudeCodePlugin.create();
|
|
const sessionName = `${SESSION_PREFIX}${Date.now()}`;
|
|
let tmpDir: string;
|
|
|
|
// Observations captured while the agent is alive
|
|
let aliveRunning = false;
|
|
let aliveActivityState: ActivityDetection | null | undefined;
|
|
let aliveSessionInfo: AgentSessionInfo | null = null;
|
|
|
|
// Observations captured after the agent exits
|
|
let exitedRunning: boolean;
|
|
let exitedActivityState: ActivityDetection | null;
|
|
let exitedSessionInfo: AgentSessionInfo | null;
|
|
|
|
beforeAll(async () => {
|
|
await killSessionsByPrefix(SESSION_PREFIX);
|
|
|
|
// Create temp workspace — resolve symlinks (macOS /tmp → /private/tmp)
|
|
const raw = await mkdtemp(join(tmpdir(), "ao-inttest-claude-"));
|
|
tmpDir = await realpath(raw);
|
|
|
|
// Spawn Claude with a task that generates observable activity (file creation)
|
|
const cmd = `CLAUDECODE= ${claudeBin} -p 'Create a file called test.txt with the content "integration test"'`;
|
|
await createSession(sessionName, cmd, tmpDir);
|
|
|
|
const handle = makeTmuxHandle(sessionName);
|
|
const session = makeSession("inttest-claude", handle, tmpDir);
|
|
|
|
// Poll until we capture "alive" observations
|
|
// Claude needs time to start, create JSONL, and begin processing
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
const running = await agent.isProcessRunning(handle);
|
|
if (running) {
|
|
aliveRunning = true;
|
|
try {
|
|
const activityState = await agent.getActivityState(session);
|
|
if (activityState?.state !== "exited") {
|
|
aliveActivityState = activityState;
|
|
// Also capture session info while alive
|
|
aliveSessionInfo = await agent.getSessionInfo(session);
|
|
break;
|
|
}
|
|
} catch {
|
|
// JSONL might not exist yet, keep polling
|
|
}
|
|
}
|
|
await sleep(1_000);
|
|
}
|
|
|
|
// Wait for agent to exit (simple task should complete within 90s)
|
|
exitedRunning = await pollUntilEqual(() => agent.isProcessRunning(handle), false, {
|
|
timeoutMs: 90_000,
|
|
intervalMs: 2_000,
|
|
});
|
|
|
|
// Capture post-exit observations
|
|
exitedActivityState = await agent.getActivityState(session);
|
|
exitedSessionInfo = await agent.getSessionInfo(session);
|
|
}, 150_000);
|
|
|
|
afterAll(async () => {
|
|
await killSession(sessionName);
|
|
if (tmpDir) {
|
|
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}, 30_000);
|
|
|
|
it("isProcessRunning → true while agent is alive", () => {
|
|
expect(aliveRunning).toBe(true);
|
|
});
|
|
|
|
it("getActivityState → returns valid non-exited state while agent is alive", () => {
|
|
expect(aliveActivityState).toBeDefined();
|
|
expect(aliveActivityState?.state).not.toBe("exited");
|
|
// May be null (no JSONL yet) or a concrete state
|
|
expect([null, "active", "ready", "idle", "waiting_input", "blocked"]).toContain(
|
|
aliveActivityState?.state ?? null,
|
|
);
|
|
});
|
|
|
|
it("getSessionInfo → returns session data while agent is alive (or null if path mismatch)", () => {
|
|
// The JSONL path depends on Claude's internal encoding of workspacePath.
|
|
// If the temp dir resolves differently (symlinks, etc.), may return null.
|
|
// Both outcomes are acceptable — the key is it doesn't throw.
|
|
if (aliveSessionInfo !== null) {
|
|
expect(aliveSessionInfo).toHaveProperty("summary");
|
|
expect(aliveSessionInfo).toHaveProperty("agentSessionId");
|
|
expect(typeof aliveSessionInfo.agentSessionId).toBe("string");
|
|
}
|
|
});
|
|
|
|
it("isProcessRunning → false after agent exits", () => {
|
|
expect(exitedRunning).toBe(false);
|
|
});
|
|
|
|
it("getActivityState → returns exited after agent process terminates", () => {
|
|
expect(exitedActivityState?.state).toBe("exited");
|
|
});
|
|
|
|
it("getSessionInfo → returns session data after agent exits (or null if path mismatch)", () => {
|
|
// JSONL should still be readable after exit, but path encoding may cause null.
|
|
// Both outcomes are acceptable — the key is it doesn't throw.
|
|
if (exitedSessionInfo !== null) {
|
|
expect(exitedSessionInfo).toHaveProperty("summary");
|
|
expect(exitedSessionInfo).toHaveProperty("agentSessionId");
|
|
expect(typeof exitedSessionInfo.agentSessionId).toBe("string");
|
|
}
|
|
});
|
|
});
|