351 lines
11 KiB
TypeScript
351 lines
11 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock @composio/core
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const { mockExecute, MockComposio } = vi.hoisted(() => {
|
|
const mockExecute = vi.fn();
|
|
const MockComposio = vi.fn().mockImplementation(() => ({
|
|
tools: { execute: mockExecute },
|
|
}));
|
|
return { mockExecute, MockComposio };
|
|
});
|
|
|
|
vi.mock("@composio/core", () => ({
|
|
Composio: MockComposio,
|
|
}));
|
|
|
|
import { create } from "../src/index.js";
|
|
import type { ProjectConfig } from "@agent-orchestrator/core";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const project: ProjectConfig = {
|
|
name: "test",
|
|
repo: "acme/integrator",
|
|
path: "/tmp/repo",
|
|
defaultBranch: "main",
|
|
sessionPrefix: "test",
|
|
tracker: {
|
|
plugin: "linear",
|
|
teamId: "team-uuid-1",
|
|
workspaceSlug: "acme",
|
|
},
|
|
};
|
|
|
|
const sampleIssueNode = {
|
|
id: "uuid-123",
|
|
identifier: "INT-123",
|
|
title: "Fix login bug",
|
|
description: "Users can't log in with SSO",
|
|
url: "https://linear.app/acme/issue/INT-123",
|
|
priority: 2,
|
|
state: { name: "In Progress", type: "started" },
|
|
labels: { nodes: [{ name: "bug" }, { name: "high-priority" }] },
|
|
assignee: { name: "Alice Smith", displayName: "Alice" },
|
|
team: { key: "INT" },
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function mockComposioResponse(data: unknown) {
|
|
mockExecute.mockResolvedValueOnce({
|
|
data,
|
|
successful: true,
|
|
});
|
|
}
|
|
|
|
function mockComposioError(error: string) {
|
|
mockExecute.mockResolvedValueOnce({
|
|
error,
|
|
successful: false,
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Environment helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let savedComposioKey: string | undefined;
|
|
let savedEntityId: string | undefined;
|
|
let savedLinearKey: string | undefined;
|
|
|
|
function saveEnv() {
|
|
savedComposioKey = process.env["COMPOSIO_API_KEY"];
|
|
savedEntityId = process.env["COMPOSIO_ENTITY_ID"];
|
|
savedLinearKey = process.env["LINEAR_API_KEY"];
|
|
}
|
|
|
|
function restoreEnv() {
|
|
if (savedComposioKey === undefined) {
|
|
delete process.env["COMPOSIO_API_KEY"];
|
|
} else {
|
|
process.env["COMPOSIO_API_KEY"] = savedComposioKey;
|
|
}
|
|
if (savedEntityId === undefined) {
|
|
delete process.env["COMPOSIO_ENTITY_ID"];
|
|
} else {
|
|
process.env["COMPOSIO_ENTITY_ID"] = savedEntityId;
|
|
}
|
|
if (savedLinearKey === undefined) {
|
|
delete process.env["LINEAR_API_KEY"];
|
|
} else {
|
|
process.env["LINEAR_API_KEY"] = savedLinearKey;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("tracker-linear Composio transport", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
saveEnv();
|
|
// Set Composio key, remove Linear key
|
|
process.env["COMPOSIO_API_KEY"] = "composio_test_key";
|
|
delete process.env["LINEAR_API_KEY"];
|
|
delete process.env["COMPOSIO_ENTITY_ID"];
|
|
});
|
|
|
|
afterEach(() => {
|
|
restoreEnv();
|
|
});
|
|
|
|
// ---- Auto-detection ---------------------------------------------------
|
|
|
|
describe("transport auto-detection", () => {
|
|
it("uses Composio transport when COMPOSIO_API_KEY is set", async () => {
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
const tracker = create();
|
|
const issue = await tracker.getIssue("INT-123", project);
|
|
|
|
expect(issue.id).toBe("INT-123");
|
|
expect(MockComposio).toHaveBeenCalledWith({ apiKey: "composio_test_key" });
|
|
expect(mockExecute).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("prefers COMPOSIO_API_KEY over LINEAR_API_KEY when both set", async () => {
|
|
process.env["LINEAR_API_KEY"] = "lin_api_test_key";
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
|
|
// Should use Composio, not direct
|
|
expect(MockComposio).toHaveBeenCalled();
|
|
expect(mockExecute).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// ---- Entity ID --------------------------------------------------------
|
|
|
|
describe("entity ID", () => {
|
|
it("defaults entity ID to 'default'", async () => {
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
|
|
expect(mockExecute).toHaveBeenCalledWith(
|
|
"LINEAR_RUN_QUERY_OR_MUTATION",
|
|
expect.objectContaining({ entityId: "default" }),
|
|
);
|
|
});
|
|
|
|
it("uses COMPOSIO_ENTITY_ID env var when set", async () => {
|
|
process.env["COMPOSIO_ENTITY_ID"] = "my-entity";
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
|
|
expect(mockExecute).toHaveBeenCalledWith(
|
|
"LINEAR_RUN_QUERY_OR_MUTATION",
|
|
expect.objectContaining({ entityId: "my-entity" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---- Successful queries -----------------------------------------------
|
|
|
|
describe("successful queries", () => {
|
|
it("returns correct Issue from getIssue", async () => {
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
const tracker = create();
|
|
const issue = await tracker.getIssue("INT-123", project);
|
|
|
|
expect(issue).toEqual({
|
|
id: "INT-123",
|
|
title: "Fix login bug",
|
|
description: "Users can't log in with SSO",
|
|
url: "https://linear.app/acme/issue/INT-123",
|
|
state: "in_progress",
|
|
labels: ["bug", "high-priority"],
|
|
assignee: "Alice",
|
|
priority: 2,
|
|
});
|
|
});
|
|
|
|
it("passes query and variables to Composio execute", async () => {
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
|
|
const call = mockExecute.mock.calls[0];
|
|
expect(call[0]).toBe("LINEAR_RUN_QUERY_OR_MUTATION");
|
|
const params = call[1] as Record<string, unknown>;
|
|
const args = params["arguments"] as Record<string, string>;
|
|
expect(args["query_or_mutation"]).toContain("query($id: String!)");
|
|
expect(JSON.parse(args["variables"])).toEqual({ id: "INT-123" });
|
|
});
|
|
|
|
it("serializes empty variables as '{}'", async () => {
|
|
mockComposioResponse({ issue: { state: { type: "completed" } } });
|
|
const tracker = create();
|
|
|
|
// isCompleted passes variables, but let's verify with listIssues which
|
|
// also always passes variables. Instead, let's test a simpler case.
|
|
await tracker.isCompleted("INT-123", project);
|
|
|
|
const call = mockExecute.mock.calls[0];
|
|
const params = call[1] as Record<string, unknown>;
|
|
const args = params["arguments"] as Record<string, string>;
|
|
// Should be valid JSON
|
|
expect(() => JSON.parse(args["variables"])).not.toThrow();
|
|
});
|
|
|
|
it("works with listIssues", async () => {
|
|
mockComposioResponse({
|
|
issues: {
|
|
nodes: [sampleIssueNode],
|
|
},
|
|
});
|
|
const tracker = create();
|
|
const issues = await tracker.listIssues!({}, project);
|
|
|
|
expect(issues).toHaveLength(1);
|
|
expect(issues[0].id).toBe("INT-123");
|
|
});
|
|
|
|
it("works with isCompleted", async () => {
|
|
mockComposioResponse({ issue: { state: { type: "completed" } } });
|
|
const tracker = create();
|
|
const result = await tracker.isCompleted("INT-123", project);
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ---- Error handling ---------------------------------------------------
|
|
|
|
describe("error handling", () => {
|
|
it("throws on unsuccessful response", async () => {
|
|
mockComposioError("Authentication failed");
|
|
const tracker = create();
|
|
|
|
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
|
"Composio Linear API error: Authentication failed",
|
|
);
|
|
});
|
|
|
|
it("throws with 'unknown error' when error field is missing", async () => {
|
|
mockExecute.mockResolvedValueOnce({
|
|
successful: false,
|
|
});
|
|
const tracker = create();
|
|
|
|
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
|
"Composio Linear API error: unknown error",
|
|
);
|
|
});
|
|
|
|
it("throws when response has no data", async () => {
|
|
mockExecute.mockResolvedValueOnce({
|
|
successful: true,
|
|
data: undefined,
|
|
});
|
|
const tracker = create();
|
|
|
|
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
|
"Composio Linear API returned no data",
|
|
);
|
|
});
|
|
|
|
it("propagates execute rejections", async () => {
|
|
mockExecute.mockRejectedValueOnce(new Error("Network error"));
|
|
const tracker = create();
|
|
|
|
await expect(tracker.getIssue("INT-123", project)).rejects.toThrow(
|
|
"Network error",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---- Client caching ---------------------------------------------------
|
|
|
|
describe("client caching", () => {
|
|
it("creates Composio client only once across multiple queries", async () => {
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
mockComposioResponse({ issue: { state: { type: "started" } } });
|
|
mockComposioResponse({
|
|
issues: { nodes: [sampleIssueNode] },
|
|
});
|
|
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
await tracker.isCompleted("INT-123", project);
|
|
await tracker.listIssues!({}, project);
|
|
|
|
// Composio constructor should be called exactly once
|
|
expect(MockComposio).toHaveBeenCalledTimes(1);
|
|
// But execute should be called 3 times
|
|
expect(mockExecute).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
// ---- Timeout ----------------------------------------------------------
|
|
|
|
describe("timeout", () => {
|
|
it("times out after 30s", async () => {
|
|
// Pre-warm the client so import() resolves before we switch to fake timers.
|
|
mockComposioResponse({ issue: sampleIssueNode });
|
|
const tracker = create();
|
|
await tracker.getIssue("INT-123", project);
|
|
|
|
// Now switch to fake timers
|
|
vi.useFakeTimers();
|
|
|
|
// Vitest's fake timers fire the setTimeout callback synchronously
|
|
// during advanceTimersByTimeAsync, before the microtask queue can
|
|
// process the .catch() handler on timeoutPromise. Suppress the
|
|
// transient unhandled rejection that vitest detects in that window.
|
|
const suppressed: unknown[] = [];
|
|
const handler = (reason: unknown) => { suppressed.push(reason); };
|
|
process.on("unhandledRejection", handler);
|
|
|
|
try {
|
|
// Make execute hang forever
|
|
mockExecute.mockImplementationOnce(
|
|
() => new Promise(() => {}), // never resolves
|
|
);
|
|
|
|
const promise = tracker.getIssue("INT-123", project);
|
|
|
|
// Advance timers past the 30s timeout
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
|
|
await expect(promise).rejects.toThrow(
|
|
"Composio Linear API request timed out after 30s",
|
|
);
|
|
} finally {
|
|
process.removeListener("unhandledRejection", handler);
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
});
|