Files
fusion/plugins/fusion-plugin-paperclip-runtime/src/__tests__/paperclip-client.test.ts
gsxdsm a6697ee7ca feat(paperclip-runtime): route Paperclip calls through paperclipai CLI in Local CLI mode
The Paperclip runtime card's Local CLI tab previously only derived an apiUrl
from the local config and then made HTTP calls itself, so the Test action and
the company/agent pickers ignored the user's onboarded CLI auth context.

Add CLI-backed variants for every Paperclip call that has a `paperclipai`
counterpart, and route through them when transport=cli — both from the
settings card and from the runtime adapter's prompt path.

- New plugin functions spawning `paperclipai … --json`:
  * probePaperclipViaCli, listCompaniesViaCli, listCompanyAgentsViaCli
    (settings card test + pickers)
  * createIssueViaCli, getIssueViaCli, agentsMeViaCli (runtime hot path)
- New dashboard routes: /providers/paperclip/cli-status, /cli-companies,
  /cli-agents (read-only façades over the plugin's CLI helpers)
- PaperclipRuntimeCard: branches on transport=cli to use the cli-* fetchers
- PaperclipRuntimeAdapter: stores transport on the session and routes
  createIssue/getIssue + identity derivation through CLI variants in CLI mode;
  raises a clear error when agentId is unset in CLI mode (paperclipai has no
  /agents/me equivalent)
- getIssueComments / wakeAgent / getRunEvents stay on HTTP (no matching
  paperclipai subcommands) and continue to use the apiKey discovered from
  the local paperclipai config, so CLI mode still works end-to-end
- Tests: 9 new paperclip-client tests covering each CLI variant + 5 new
  adapter tests for the Local-CLI transport branch (64/64 plugin tests pass)
- Update routes.test for the existing BUNDLED_PLUGIN_RUNTIMES fallback so
  the bundled hermes/openclaw/paperclip entries are expected alongside
  installed plugins

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 23:06:20 -07:00

751 lines
24 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
agentsMe,
agentsMeViaCli,
createIssue,
createIssueViaCli,
getIssue,
getIssueComments,
getIssueViaCli,
getRunEvents,
listCompaniesViaCli,
listCompanyAgents,
listCompanyAgentsViaCli,
mintAgentApiKeyViaCli,
probePaperclipConnection,
probePaperclipViaCli,
resolvePaperclipConfig,
wakeAgent,
} from "../paperclip-client.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
function networkError(): never {
throw new TypeError("fetch failed: ECONNREFUSED");
}
afterEach(() => {
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// resolvePaperclipConfig
// ---------------------------------------------------------------------------
describe("resolvePaperclipConfig", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
vi.restoreAllMocks();
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("prefers plugin settings over env vars", () => {
process.env.PAPERCLIP_API_URL = "http://env-host:3100";
process.env.PAPERCLIP_API_KEY = "env-key";
const config = resolvePaperclipConfig({
apiUrl: "http://settings-host:4000/",
apiKey: "settings-key",
agentId: "AG-set",
companyId: "CO-set",
mode: "issue-per-prompt",
});
expect(config.apiUrl).toBe("http://settings-host:4000");
expect(config.apiKey).toBe("settings-key");
expect(config.agentId).toBe("AG-set");
expect(config.companyId).toBe("CO-set");
expect(config.mode).toBe("issue-per-prompt");
});
it("falls back to env vars then defaults", () => {
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_AGENT_ID;
delete process.env.PAPERCLIP_COMPANY_ID;
delete process.env.PAPERCLIP_RUNTIME_MODE;
const config = resolvePaperclipConfig();
expect(config.apiUrl).toBe("http://localhost:3100");
expect(config.apiKey).toBeUndefined();
expect(config.mode).toBe("rolling-issue");
expect(config.runTimeoutMs).toBe(600_000);
});
});
// ---------------------------------------------------------------------------
// agentsMe
// ---------------------------------------------------------------------------
describe("agentsMe", () => {
it("happy path — returns parsed identity", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse({
id: "AG-1",
name: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
}),
);
vi.stubGlobal("fetch", fetchMock);
const result = await agentsMe("http://localhost:3100", "key");
expect(result).toEqual({
agentId: "AG-1",
agentName: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
});
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/agents/me",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer key" }),
}),
);
});
it("no apiKey → no Authorization header", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "AG-1", companyId: "CO-1" }));
vi.stubGlobal("fetch", fetchMock);
await agentsMe("http://localhost:3100");
const call = fetchMock.mock.calls[0][1] as RequestInit;
const headers = call.headers as Record<string, string>;
expect(headers.Authorization).toBeUndefined();
});
it("401 → throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
await expect(agentsMe("http://localhost:3100", "bad")).rejects.toThrow(
/401/,
);
});
it("connect refused → throws", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
await expect(agentsMe("http://localhost:3100", "k")).rejects.toThrow();
});
});
// ---------------------------------------------------------------------------
// createIssue
// ---------------------------------------------------------------------------
describe("createIssue", () => {
it("posts correct body to /companies/{id}/issues", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "todo" }));
vi.stubGlobal("fetch", fetchMock);
await createIssue("http://localhost:3100", "k", "CO-1", {
title: "Fix bug",
description: "details",
status: "todo",
assigneeAgentId: "AG-1",
projectId: "PROJ-1",
});
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:3100/api/companies/CO-1/issues");
expect(opts.method).toBe("POST");
expect(JSON.parse(opts.body as string)).toMatchObject({
title: "Fix bug",
assigneeAgentId: "AG-1",
projectId: "PROJ-1",
});
});
});
// ---------------------------------------------------------------------------
// wakeAgent
// ---------------------------------------------------------------------------
describe("wakeAgent", () => {
it("posts wakeup with idempotency key", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "RUN-1", status: "queued" }));
vi.stubGlobal("fetch", fetchMock);
const result = await wakeAgent("http://localhost:3100", "k", "AG-1", {
source: "on_demand",
triggerDetail: "manual",
idempotencyKey: "session-1:1",
payload: { hello: "world" },
});
expect(result).toEqual({ id: "RUN-1", status: "queued" });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("http://localhost:3100/api/agents/AG-1/wakeup");
expect(JSON.parse(opts.body as string).idempotencyKey).toBe("session-1:1");
});
});
// ---------------------------------------------------------------------------
// getRunEvents
// ---------------------------------------------------------------------------
describe("getRunEvents", () => {
it("uses afterSeq + limit query params", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse([{ seq: 5, type: "heartbeat.run.status", payload: {} }]));
vi.stubGlobal("fetch", fetchMock);
const result = await getRunEvents("http://localhost:3100", "k", "RUN-1", 4, 50);
expect(result).toEqual([{ seq: 5, type: "heartbeat.run.status", payload: {} }]);
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain("/api/heartbeat-runs/RUN-1/events?");
expect(url).toContain("afterSeq=4");
expect(url).toContain("limit=50");
});
it("accepts both bare-array and { events: [...] } envelopes", async () => {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(jsonResponse({ events: [{ seq: 1, type: "x", payload: {} }] }))
.mockResolvedValueOnce(jsonResponse([{ seq: 2, type: "y", payload: {} }])),
);
const a = await getRunEvents("http://localhost:3100", "k", "R", 0);
const b = await getRunEvents("http://localhost:3100", "k", "R", 0);
expect(a.map((e) => e.type)).toEqual(["x"]);
expect(b.map((e) => e.type)).toEqual(["y"]);
});
});
// ---------------------------------------------------------------------------
// getIssue / getIssueComments
// ---------------------------------------------------------------------------
describe("getIssue / getIssueComments", () => {
it("getIssue fetches /issues/{id}", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ id: "ISS-1", status: "done" }));
vi.stubGlobal("fetch", fetchMock);
await getIssue("http://localhost:3100", "k", "ISS-1");
expect(fetchMock.mock.calls[0][0]).toBe(
"http://localhost:3100/api/issues/ISS-1",
);
});
it("getIssueComments returns array", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse([{ id: "C-1", body: "hi" }]));
vi.stubGlobal("fetch", fetchMock);
const result = await getIssueComments("http://localhost:3100", "k", "ISS-1");
expect(result).toEqual([{ id: "C-1", body: "hi" }]);
});
});
// ---------------------------------------------------------------------------
// listCompanyAgents
// ---------------------------------------------------------------------------
describe("listCompanyAgents", () => {
it("returns mapped agent summaries", async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse([
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1" },
]),
);
vi.stubGlobal("fetch", fetchMock);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result).toEqual([
{ id: "AG-1", name: "Coder", role: "engineer", companyId: "CO-1", status: "active" },
{ id: "AG-2", name: "Reviewer", role: "reviewer", companyId: "CO-1", status: undefined },
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://localhost:3100/api/companies/CO-1/agents",
expect.objectContaining({
method: "GET",
headers: expect.objectContaining({ Authorization: "Bearer k" }),
}),
);
});
it("non-array response → empty list", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "unexpected" })),
);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result).toEqual([]);
});
it("skips entries missing id; falls back name=id", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse([
{ id: "AG-1", name: "Ok", companyId: "CO-1" },
{ name: "Missing-id", companyId: "CO-1" },
null,
{ id: "AG-2", companyId: "CO-1" },
]),
),
);
const result = await listCompanyAgents("http://localhost:3100", "k", "CO-1");
expect(result.map((a) => a.id)).toEqual(["AG-1", "AG-2"]);
expect(result[1]).toMatchObject({ id: "AG-2", name: "AG-2" });
});
it("401 → throws", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
await expect(
listCompanyAgents("http://localhost:3100", "bad", "CO-1"),
).rejects.toThrow(/401/);
});
});
// ---------------------------------------------------------------------------
// probePaperclipConnection
// ---------------------------------------------------------------------------
describe("probePaperclipConnection", () => {
it("200 → available + identity", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
jsonResponse({
id: "AG-1",
name: "Coder",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
}),
),
);
const result = await probePaperclipConnection({
apiUrl: "http://localhost:3100",
apiKey: "k",
});
expect(result.available).toBe(true);
expect(result.identity).toMatchObject({
agentId: "AG-1",
agentName: "Coder",
companyId: "CO-1",
});
});
it("401 → unavailable with reason", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(jsonResponse({ error: "Unauthorized" }, 401)),
);
const result = await probePaperclipConnection({
apiUrl: "http://localhost:3100",
apiKey: "bad",
});
expect(result.available).toBe(false);
expect(result.reason).toMatch(/rejected|401/i);
});
it("connect refused → unavailable", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation(() => networkError()));
const result = await probePaperclipConnection({
apiUrl: "http://localhost:9999",
apiKey: "k",
});
expect(result.available).toBe(false);
expect(result.reason).toMatch(/not reachable|ECONNREFUSED|fetch failed/i);
});
});
// ---------------------------------------------------------------------------
// mintAgentApiKeyViaCli
// ---------------------------------------------------------------------------
describe("mintAgentApiKeyViaCli", () => {
// We mock node:child_process.spawn for each case.
it("success path — parses apiKey from JSON", async () => {
const mockPayload = {
apiKey: "sk-test-mint-key",
apiBase: "http://localhost:3100",
agentId: "AG-1",
companyId: "CO-1",
};
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([Buffer.from(JSON.stringify(mockPayload))]);
const fakeStderr = Readable.from([]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
// Emit close asynchronously after mock is in place
setImmediate(() => {
fakeChild.emit("close", 0);
});
const result = await mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" });
expect(result.apiKey).toBe("sk-test-mint-key");
expect(result.apiBase).toBe("http://localhost:3100");
expect(result.agentId).toBe("AG-1");
expect(result.companyId).toBe("CO-1");
vi.doUnmock("node:child_process");
});
it("ENOENT on spawn error → throws with install hint", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from([]);
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from([]);
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
const err = Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" });
fakeChild.emit("error", err);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", cliBinaryPath: "/usr/local/bin/paperclipai", companyId: "CO-1" }),
).rejects.toThrow(/binary not found.*npm i -g paperclipai/i);
vi.doUnmock("node:child_process");
});
it("non-zero exit → throws with stderr hint", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([]);
const fakeStderr = Readable.from([Buffer.from("Error: CLI is not authenticated\n")]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
fakeChild.emit("close", 1);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
).rejects.toThrow(/exited 1.*paperclipai onboard/i);
vi.doUnmock("node:child_process");
});
it("malformed JSON output → throws", async () => {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeStdout = Readable.from([Buffer.from("not-json-at-all")]);
const fakeStderr = Readable.from([]);
const fakeChild = new EventEmitter() as ReturnType<typeof import("node:child_process").spawn>;
(fakeChild as unknown as Record<string, unknown>).stdout = fakeStdout;
(fakeChild as unknown as Record<string, unknown>).stderr = fakeStderr;
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
setImmediate(() => {
fakeChild.emit("close", 0);
});
await expect(
mintAgentApiKeyViaCli({ agentRef: "my-agent", companyId: "CO-1" }),
).rejects.toThrow(/non-JSON output/i);
vi.doUnmock("node:child_process");
});
});
// ---------------------------------------------------------------------------
// CLI-backed variants — createIssueViaCli, getIssueViaCli, agentsMeViaCli,
// listCompaniesViaCli, listCompanyAgentsViaCli, probePaperclipViaCli.
//
// All of these spawn `paperclipai … --json`; we mock node:child_process the
// same way the mintAgentApiKeyViaCli suite does and assert both the argv we
// pass to the CLI and the parsed return shape.
// ---------------------------------------------------------------------------
interface FakeSpawnHandle {
stdoutChunks: string[];
stderrChunks: string[];
exitCode: number | null;
errorOnSpawn?: NodeJS.ErrnoException;
}
async function withFakeSpawn<T>(
handle: FakeSpawnHandle,
run: (spawnMock: ReturnType<typeof vi.fn>) => Promise<T>,
): Promise<T> {
const { EventEmitter } = await import("node:events");
const { Readable } = await import("node:stream");
const fakeChild = new EventEmitter() as ReturnType<
typeof import("node:child_process").spawn
>;
(fakeChild as unknown as Record<string, unknown>).stdout = Readable.from(
handle.stdoutChunks.map((c) => Buffer.from(c)),
);
(fakeChild as unknown as Record<string, unknown>).stderr = Readable.from(
handle.stderrChunks.map((c) => Buffer.from(c)),
);
(fakeChild as unknown as Record<string, unknown>).kill = vi.fn();
const spawnMock = vi.fn().mockReturnValue(fakeChild);
vi.doMock("node:child_process", () => ({ spawn: spawnMock }));
// Wait for the spawn caller to attach `close`/`error` listeners before
// emitting — `probePaperclipViaCli` does an extra filesystem await before
// spawning, so emitting eagerly via setImmediate races the listener
// registration and never fires.
const tryEmit = () => {
if (
fakeChild.listenerCount("close") > 0 ||
fakeChild.listenerCount("error") > 0
) {
if (handle.errorOnSpawn) {
fakeChild.emit("error", handle.errorOnSpawn);
} else {
fakeChild.emit("close", handle.exitCode ?? 0);
}
return;
}
setImmediate(tryEmit);
};
setImmediate(tryEmit);
try {
return await run(spawnMock);
} finally {
vi.doUnmock("node:child_process");
}
}
describe("createIssueViaCli", () => {
it("spawns `paperclipai issue create` with the right argv and parses JSON", async () => {
const created = { id: "ISS-1", status: "todo" };
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(created)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await createIssueViaCli({
companyId: "CO-1",
body: {
title: "Hello",
description: "Body",
status: "todo",
assigneeAgentId: "AG-1",
parentId: "ISS-0",
projectId: "PR-1",
goalId: "GO-1",
},
cliBinaryPath: "/opt/bin/paperclipai",
cliConfigPath: "/cfg.json",
});
expect(r.id).toBe("ISS-1");
const argv = spawnMock.mock.calls[0]![1] as string[];
expect(spawnMock.mock.calls[0]![0]).toBe("/opt/bin/paperclipai");
expect(argv).toEqual([
"issue",
"create",
"--company-id",
"CO-1",
"--title",
"Hello",
"--description",
"Body",
"--status",
"todo",
"--assignee-agent-id",
"AG-1",
"--parent-id",
"ISS-0",
"--project-id",
"PR-1",
"--goal-id",
"GO-1",
"--json",
"--config",
"/cfg.json",
]);
},
);
});
it("rejects when the CLI returns a non-object payload", async () => {
await withFakeSpawn(
{ stdoutChunks: ["[1,2,3]"], stderrChunks: [], exitCode: 0 },
async () => {
await expect(
createIssueViaCli({
companyId: "CO-1",
body: { title: "x", description: "y", status: "todo", assigneeAgentId: "a" },
}),
).rejects.toThrow(/unexpected payload/i);
},
);
});
});
describe("getIssueViaCli", () => {
it("spawns `paperclipai issue get <id>` and returns the parsed object", async () => {
const issue = { id: "ISS-1", status: "in_progress" };
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(issue)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await getIssueViaCli({ issueId: "ISS-1" });
expect(r.status).toBe("in_progress");
const argv = spawnMock.mock.calls[0]![1] as string[];
expect(argv.slice(0, 3)).toEqual(["issue", "get", "ISS-1"]);
expect(argv).toContain("--json");
},
);
});
});
describe("agentsMeViaCli", () => {
it("returns identity from `paperclipai agent get <id> --json`", async () => {
const agent = {
id: "AG-1",
name: "Bot",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
};
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(agent)], stderrChunks: [], exitCode: 0 },
async () => {
const r = await agentsMeViaCli({ agentId: "AG-1" });
expect(r).toEqual({
agentId: "AG-1",
agentName: "Bot",
role: "engineer",
companyId: "CO-1",
companyName: "Acme",
});
},
);
});
it("throws when payload is missing required fields", async () => {
await withFakeSpawn(
{ stdoutChunks: ['{"name":"orphan"}'], stderrChunks: [], exitCode: 0 },
async () => {
await expect(agentsMeViaCli({ agentId: "AG-1" })).rejects.toThrow(
/missing `id` or `companyId`/i,
);
},
);
});
});
describe("listCompaniesViaCli / listCompanyAgentsViaCli", () => {
it("listCompaniesViaCli projects array entries", async () => {
const payload = [
{ id: "CO-1", name: "Acme", urlKey: "acme" },
{ id: "CO-2", name: "Beta" },
];
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(payload)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await listCompaniesViaCli({});
expect(r).toEqual([
{ id: "CO-1", name: "Acme", urlKey: "acme" },
{ id: "CO-2", name: "Beta", urlKey: undefined },
]);
expect(spawnMock.mock.calls[0]![1]).toEqual([
"company",
"list",
"--json",
]);
},
);
});
it("listCompanyAgentsViaCli passes --company-id and projects entries", async () => {
const payload = [{ id: "AG-1", name: "Bot", role: "engineer", companyId: "CO-1" }];
await withFakeSpawn(
{ stdoutChunks: [JSON.stringify(payload)], stderrChunks: [], exitCode: 0 },
async (spawnMock) => {
const r = await listCompanyAgentsViaCli({ companyId: "CO-1" });
expect(r).toEqual([
{ id: "AG-1", name: "Bot", role: "engineer", companyId: "CO-1", status: undefined },
]);
expect(spawnMock.mock.calls[0]![1]).toEqual([
"agent",
"list",
"--company-id",
"CO-1",
"--json",
]);
},
);
});
});
describe("probePaperclipViaCli", () => {
it("returns available:true when `company list` succeeds", async () => {
await withFakeSpawn(
{ stdoutChunks: ["[]"], stderrChunks: [], exitCode: 0 },
async () => {
const r = await probePaperclipViaCli({});
expect(r.available).toBe(true);
expect(typeof r.probeDurationMs).toBe("number");
},
);
});
it("returns available:false with the stderr reason on failure", async () => {
await withFakeSpawn(
{
stdoutChunks: [],
stderrChunks: ["Could not reach the Paperclip API."],
exitCode: 1,
},
async () => {
const r = await probePaperclipViaCli({});
expect(r.available).toBe(false);
expect(r.reason).toMatch(/Could not reach/);
},
);
});
});