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 "@composio/ao-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; const args = params["arguments"] as Record; 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; const args = params["arguments"] as Record; // 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(); } }); }); });