agent-orchestrator/packages/integration-tests/src/metadata-lifecycle.integrat...

337 lines
11 KiB
TypeScript

/**
* Integration test for metadata lifecycle — real filesystem operations.
*
* Tests the full metadata CRUD cycle (write, read, update, list, delete/archive)
* and concurrent access patterns using @composio/ao-core metadata functions
* with real filesystem I/O.
*/
import { mkdtemp, rm } from "node:fs/promises";
import { existsSync, readdirSync, readFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
writeMetadata,
readMetadata,
readMetadataRaw,
updateMetadata,
deleteMetadata,
listMetadata,
type SessionMetadata,
} from "@composio/ao-core";
describe("metadata lifecycle (real filesystem)", () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "ao-meta-lifecycle-"));
});
afterAll(async () => {
if (tmpDir) {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
});
it("write + read round-trip preserves all fields", () => {
const sessionsDir = join(tmpDir, "test-roundtrip");
mkdirSync(sessionsDir, { recursive: true });
const metadata: SessionMetadata = {
worktree: "/tmp/wt/session-1",
branch: "feat/INT-100",
status: "working",
tmuxName: "abc123-session-1",
issue: "INT-100",
pr: "https://github.com/org/repo/pull/42",
summary: "Implementing feature INT-100",
project: "my-project",
createdAt: "2026-01-01T00:00:00.000Z",
dashboardPort: 4000,
};
writeMetadata(sessionsDir, "session-1", metadata);
const result = readMetadata(sessionsDir, "session-1");
expect(result).not.toBeNull();
expect(result!.worktree).toBe(metadata.worktree);
expect(result!.branch).toBe(metadata.branch);
expect(result!.status).toBe(metadata.status);
expect(result!.tmuxName).toBe(metadata.tmuxName);
expect(result!.issue).toBe(metadata.issue);
expect(result!.pr).toBe(metadata.pr);
expect(result!.summary).toBe(metadata.summary);
expect(result!.project).toBe(metadata.project);
expect(result!.createdAt).toBe(metadata.createdAt);
expect(result!.dashboardPort).toBe(4000);
});
it("readMetadataRaw returns all key-value pairs", () => {
const sessionsDir = join(tmpDir, "test-raw");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-raw", {
worktree: "/w",
branch: "main",
status: "idle",
project: "proj",
});
const raw = readMetadataRaw(sessionsDir, "session-raw");
expect(raw).not.toBeNull();
expect(raw!["worktree"]).toBe("/w");
expect(raw!["branch"]).toBe("main");
expect(raw!["status"]).toBe("idle");
expect(raw!["project"]).toBe("proj");
});
it("read returns null for non-existent session", () => {
const sessionsDir = join(tmpDir, "test-nonexistent");
mkdirSync(sessionsDir, { recursive: true });
const result = readMetadata(sessionsDir, "no-such-session");
expect(result).toBeNull();
});
it("updateMetadata merges fields into existing file", () => {
const sessionsDir = join(tmpDir, "test-update");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-upd", {
worktree: "/w",
branch: "feat/x",
status: "spawning",
project: "proj",
});
updateMetadata(sessionsDir, "session-upd", {
status: "working",
pr: "https://github.com/org/repo/pull/99",
});
const result = readMetadata(sessionsDir, "session-upd");
expect(result!.status).toBe("working");
expect(result!.pr).toBe("https://github.com/org/repo/pull/99");
// Original fields preserved
expect(result!.worktree).toBe("/w");
expect(result!.branch).toBe("feat/x");
expect(result!.project).toBe("proj");
});
it("updateMetadata removes keys set to empty string", () => {
const sessionsDir = join(tmpDir, "test-remove-key");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-rmk", {
worktree: "/w",
branch: "main",
status: "working",
summary: "Remove me",
});
updateMetadata(sessionsDir, "session-rmk", {
summary: "",
});
const raw = readMetadataRaw(sessionsDir, "session-rmk");
expect(raw!["summary"]).toBeUndefined();
expect(raw!["worktree"]).toBe("/w");
});
it("updateMetadata creates file if it does not exist", () => {
const sessionsDir = join(tmpDir, "test-update-create");
mkdirSync(sessionsDir, { recursive: true });
updateMetadata(sessionsDir, "session-new", {
worktree: "/w",
branch: "main",
status: "spawning",
});
const result = readMetadata(sessionsDir, "session-new");
expect(result).not.toBeNull();
expect(result!.status).toBe("spawning");
});
it("listMetadata returns session IDs, excluding archive directory", () => {
const sessionsDir = join(tmpDir, "test-list");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-a", { worktree: "/a", branch: "a", status: "idle" });
writeMetadata(sessionsDir, "session-b", { worktree: "/b", branch: "b", status: "working" });
writeMetadata(sessionsDir, "session-c", { worktree: "/c", branch: "c", status: "done" });
const ids = listMetadata(sessionsDir);
expect(ids).toContain("session-a");
expect(ids).toContain("session-b");
expect(ids).toContain("session-c");
expect(ids).not.toContain("archive");
});
it("deleteMetadata with archive=true moves file to archive/", () => {
const sessionsDir = join(tmpDir, "test-archive");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-del", {
worktree: "/w",
branch: "main",
status: "working",
});
expect(existsSync(join(sessionsDir, "session-del"))).toBe(true);
deleteMetadata(sessionsDir, "session-del", true);
// Original file removed
expect(existsSync(join(sessionsDir, "session-del"))).toBe(false);
// Archive created
const archiveDir = join(sessionsDir, "archive");
expect(existsSync(archiveDir)).toBe(true);
const archived = readdirSync(archiveDir);
expect(archived.length).toBe(1);
expect(archived[0]).toMatch(/^session-del_/);
// Archive content matches original
const content = readFileSync(join(archiveDir, archived[0]), "utf-8");
expect(content).toContain("worktree=/w");
expect(content).toContain("branch=main");
});
it("deleteMetadata with archive=false permanently removes file", () => {
const sessionsDir = join(tmpDir, "test-permanent-delete");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "session-gone", {
worktree: "/w",
branch: "main",
status: "done",
});
deleteMetadata(sessionsDir, "session-gone", false);
expect(existsSync(join(sessionsDir, "session-gone"))).toBe(false);
expect(existsSync(join(sessionsDir, "archive"))).toBe(false);
});
it("deleteMetadata is a no-op for non-existent session", () => {
const sessionsDir = join(tmpDir, "test-delete-noop");
mkdirSync(sessionsDir, { recursive: true });
// Should not throw
deleteMetadata(sessionsDir, "no-such-session", true);
deleteMetadata(sessionsDir, "no-such-session", false);
});
it("validates session ID rejects path traversal attempts", () => {
const sessionsDir = join(tmpDir, "test-validation");
mkdirSync(sessionsDir, { recursive: true });
expect(() => readMetadata(sessionsDir, "../escape")).toThrow("Invalid session ID");
expect(() => readMetadata(sessionsDir, "foo/bar")).toThrow("Invalid session ID");
expect(() => readMetadata(sessionsDir, "foo bar")).toThrow("Invalid session ID");
});
describe("concurrent access patterns", () => {
it("concurrent writes to different sessions do not interfere", async () => {
const sessionsDir = join(tmpDir, "test-concurrent-different");
mkdirSync(sessionsDir, { recursive: true });
// Write 20 sessions concurrently
const count = 20;
const promises = Array.from({ length: count }, (_, i) =>
Promise.resolve().then(() => {
writeMetadata(sessionsDir, `concurrent-${i}`, {
worktree: `/w/${i}`,
branch: `branch-${i}`,
status: "working",
issue: `ISSUE-${i}`,
});
}),
);
await Promise.all(promises);
// Verify all sessions were written correctly
const ids = listMetadata(sessionsDir);
expect(ids.length).toBe(count);
for (let i = 0; i < count; i++) {
const meta = readMetadata(sessionsDir, `concurrent-${i}`);
expect(meta).not.toBeNull();
expect(meta!.worktree).toBe(`/w/${i}`);
expect(meta!.branch).toBe(`branch-${i}`);
expect(meta!.issue).toBe(`ISSUE-${i}`);
}
});
it("concurrent updates to the same session preserve last-write wins", async () => {
const sessionsDir = join(tmpDir, "test-concurrent-same");
mkdirSync(sessionsDir, { recursive: true });
// Create initial session
writeMetadata(sessionsDir, "shared-session", {
worktree: "/w",
branch: "main",
status: "spawning",
});
// Rapidly update the same session from multiple "tasks"
const updates = Array.from({ length: 10 }, (_, i) =>
Promise.resolve().then(() => {
updateMetadata(sessionsDir, "shared-session", {
status: `status-${i}`,
summary: `Update ${i}`,
});
}),
);
await Promise.all(updates);
// Verify the file is valid (not corrupted)
const result = readMetadata(sessionsDir, "shared-session");
expect(result).not.toBeNull();
expect(result!.worktree).toBe("/w");
expect(result!.branch).toBe("main");
// Status should be one of the updates (last-write-wins)
expect(result!.status).toMatch(/^status-\d$/);
expect(result!.summary).toMatch(/^Update \d$/);
});
});
describe("dashboardPort serialization", () => {
it("preserves dashboardPort through write/read cycle", () => {
const sessionsDir = join(tmpDir, "test-dashboard-port");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "port-session", {
worktree: "/w",
branch: "main",
status: "working",
dashboardPort: 4567,
});
const result = readMetadata(sessionsDir, "port-session");
expect(result!.dashboardPort).toBe(4567);
});
it("omits dashboardPort when undefined", () => {
const sessionsDir = join(tmpDir, "test-no-dashboard-port");
mkdirSync(sessionsDir, { recursive: true });
writeMetadata(sessionsDir, "no-port-session", {
worktree: "/w",
branch: "main",
status: "working",
});
const raw = readMetadataRaw(sessionsDir, "no-port-session");
expect(raw!["dashboardPort"]).toBeUndefined();
const result = readMetadata(sessionsDir, "no-port-session");
expect(result!.dashboardPort).toBeUndefined();
});
});
});