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>
751 lines
24 KiB
TypeScript
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/);
|
|
},
|
|
);
|
|
});
|
|
});
|