test: push coverage to 90%+ on lines for all files except execute.ts (FAR-85)

Overall before: 80.36% lines / 79.06% statements
Overall after:  94.65% lines / 93.30% statements

Per-file lines coverage (all targets ≥90% except execute.ts):

| File              | Before | After  |
|-------------------|--------|--------|
| ui-parser.ts      | 93.63% | 99.09% |
| cli/format-event  | 59.85% | 99.27% |
| server/execute    | 81.47% | 89.64% |
| server/job-mfst   | 90.30% | 98.78% |
| server/k8s-client | 37.50% | 95.83% |
| server/log-dedup  | 97.77% | 97.77% |
| server/parse      | 89.85% | 98.55% |
| server/skills     | 100%   | 100%   |

New tests added:

- k8s-client.test.ts: getSelfPodInfo (env-var inheritance, secret volumes,
  PVC discovery, dnsConfig, all error paths) + kubeconfig file branch
- format-event.test.ts: parseStdoutLine (cli) — full event-type matrix,
  tool_use status branches, errorText fallback paths
- ui-parser.test.ts: errorText edge cases, empty event paths
- parse.test.ts: errorText fallback to data.message, name, code, JSON
- job-manifest.test.ts: workspace context env wiring, linkedIssueIds,
  paperclipWorkspaces/RuntimeServices JSON, authToken, inherited URLs,
  prompt-secret + data PVC + secret-volume mount paths
- execute.test.ts: parseModelProvider, completionWithGrace,
  instructionsFilePath read failure, ensureAgentDbPvc throw paths,
  large-prompt secret create failure, step-limit detection,
  waitForPod no-pod messaging, init-container ImagePullBackOff /
  CrashLoopBackOff, main-container CrashLoopBackOff, all-inits-done
  happy path, skill bundle source loading (SKILL.md + flat-file
  fallback), SIGTERM handler full body via vi.resetModules()

execute.ts remains at 89.64% lines — the residual gap is deep async/timer
paths inside streamAndAwaitJob (grace poller, keepalive ticker, log-stream
stop-signal/bail timer). Those need fake-timer scaffolding heavier than
this batch warrants; tracking separately.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-25 22:27:04 +00:00
committed by Hugh Commit [agent]
parent 693016d1ab
commit 798b80f2f2
8 changed files with 897 additions and 5 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.28",
"version": "0.1.29",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+178
View File
@@ -248,3 +248,181 @@ describe("formatEvent", () => {
});
});
});
import { parseStdoutLine } from "./format-event.js";
describe("parseStdoutLine (cli)", () => {
const TS = "2026-04-25T22:00:00.000Z";
it("returns empty for empty input", () => {
expect(parseStdoutLine("", TS)).toEqual([]);
expect(parseStdoutLine(" ", TS)).toEqual([]);
});
it("returns stdout entry for non-JSON input", () => {
expect(parseStdoutLine("plain log", TS)).toEqual([{ kind: "stdout", ts: TS, text: "plain log" }]);
});
it("returns stdout entry when JSON parses to a non-object primitive", () => {
expect(parseStdoutLine("42", TS)).toEqual([{ kind: "stdout", ts: TS, text: "42" }]);
});
it("renders a text event as an assistant delta", () => {
const line = JSON.stringify({ type: "text", part: { text: "Hello" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "Hello", delta: true }]);
});
it("returns empty for text event with empty text", () => {
const line = JSON.stringify({ type: "text", part: { text: "" } });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("renders tool_use status=error as tool_result with isError", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error", error: "boom" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "boom", isError: true },
]);
});
it("uses 'Tool error' fallback when error event has no error string", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "error" } } });
const result = parseStdoutLine(line, TS);
expect((result[0] as { content: string }).content).toBe("Tool error");
});
it("renders tool_use status=completed as tool_result with output", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "completed", output: "ok" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_result", ts: TS, toolUseId: "t1", toolName: "bash", content: "ok", isError: false },
]);
});
it("renders tool_use status=done — falls back to description when no output", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done", description: "did it" } } });
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("did it");
});
it("renders tool_use status=done — falls back to 'Done' when no output or description", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "done" } } });
expect((parseStdoutLine(line, TS)[0] as { content: string }).content).toBe("Done");
});
it("renders tool_use pending status as tool_call", () => {
const line = JSON.stringify({ type: "tool_use", part: { tool: "bash", id: "t1", state: { status: "running", description: "go" } } });
expect(parseStdoutLine(line, TS)).toEqual([
{ kind: "tool_call", ts: TS, name: "bash", input: "go", toolUseId: "t1" },
]);
});
it("falls back to part.type then 'tool' when no part.tool name", () => {
const line = JSON.stringify({ type: "tool_use", part: { type: "edit", state: { status: "running" } } });
expect((parseStdoutLine(line, TS)[0] as { name: string }).name).toBe("edit");
const line2 = JSON.stringify({ type: "tool_use", part: { state: { status: "running" } } });
expect((parseStdoutLine(line2, TS)[0] as { name: string }).name).toBe("tool");
});
it("renders step_finish with token/cost metrics", () => {
const line = JSON.stringify({
type: "step_finish",
part: {
message: "did the thing",
reason: "stop",
tokens: { input: 100, output: 50, reasoning: 10, cache: { read: 30 } },
cost: 0.0123,
},
});
const result = parseStdoutLine(line, TS);
expect(result).toEqual([{
kind: "result",
ts: TS,
text: "did the thing",
inputTokens: 100,
outputTokens: 60,
cachedTokens: 30,
costUsd: 0.0123,
subtype: "stop",
isError: false,
errors: [],
}]);
});
it("renders step_finish with default text when no message", () => {
const line = JSON.stringify({ type: "step_finish", part: { reason: "stop" } });
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toBe("Step finished: stop");
const line2 = JSON.stringify({ type: "step_finish", part: {} });
expect((parseStdoutLine(line2, TS)[0] as { text: string }).text).toBe("Step finished: done");
});
it("renders step_start as a system entry", () => {
const line = JSON.stringify({ type: "step_start" });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "system", ts: TS, text: "Starting step…" }]);
});
it("renders assistant event with nested text content", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: [{ type: "text", text: "hi there" }] } },
});
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "hi there" }]);
});
it("handles assistant content as a single non-array object", () => {
const line = JSON.stringify({
type: "assistant",
part: { message: { content: { type: "text", text: "single" } } },
});
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "assistant", ts: TS, text: "single" }]);
});
it("returns empty for assistant event with no extractable text", () => {
const line = JSON.stringify({ type: "assistant", part: { message: { content: [{ type: "image" }] } } });
expect(parseStdoutLine(line, TS)).toEqual([]);
const line2 = JSON.stringify({ type: "assistant", part: {} });
expect(parseStdoutLine(line2, TS)).toEqual([]);
});
it("renders error event with errorText", () => {
const line = JSON.stringify({ type: "error", error: { message: "broken" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "broken" }]);
});
it("returns empty for error event with empty error string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
it("uses error.code fallback in errorText", () => {
const line = JSON.stringify({ type: "error", error: { code: "E_X" } });
expect(parseStdoutLine(line, TS)).toEqual([{ kind: "stderr", ts: TS, text: "E_X" }]);
});
it("uses nested data.message and name fallbacks in errorText", () => {
const l1 = JSON.stringify({ type: "error", error: { data: { message: "nested" } } });
expect((parseStdoutLine(l1, TS)[0] as { text: string }).text).toBe("nested");
const l2 = JSON.stringify({ type: "error", error: { name: "ProviderErr" } });
expect((parseStdoutLine(l2, TS)[0] as { text: string }).text).toBe("ProviderErr");
});
it("falls back to JSON.stringify of the error object when nothing else matches", () => {
const line = JSON.stringify({ type: "error", error: { weirdKey: "x" } });
expect((parseStdoutLine(line, TS)[0] as { text: string }).text).toContain("weirdKey");
});
it("returns empty array for unknown event types", () => {
const line = JSON.stringify({ type: "totally_unknown" });
expect(parseStdoutLine(line, TS)).toEqual([]);
});
});
describe("formatEvent — additional coverage", () => {
it("returns empty for safeJsonParse of a non-object primitive", () => {
// formatEvent treats a non-object as non-JSON and returns the trimmed line as-is
const result = formatEvent("42", false);
expect(result).toBe("42");
});
it("returns empty for error event with empty error string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(formatEvent(line, false)).toBe("");
});
});
+376
View File
@@ -1212,3 +1212,379 @@ describe("isK8s404", () => {
expect(isK8s404(null)).toBe(false);
});
});
describe("parseModelProvider", () => {
it("returns null for null input", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(null)).toBeNull();
});
it("returns null when model has no slash separator", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider("gpt-4")).toBeNull();
expect(parseModelProvider(" ")).toBeNull();
});
it("returns the provider segment from a slash-separated model id", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider("anthropic/claude-opus-4")).toBe("anthropic");
expect(parseModelProvider("openai/gpt-4o")).toBe("openai");
});
it("trims whitespace inside the provider segment", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(" bedrock /claude")).toBe("bedrock");
});
it("returns null when provider segment is whitespace only", async () => {
const { parseModelProvider } = await import("./execute.js");
expect(parseModelProvider(" /model")).toBeNull();
});
});
describe("completionWithGrace", () => {
it("returns the completion result when it resolves before grace expires", async () => {
const { completionWithGrace } = await import("./execute.js");
const result = await completionWithGrace(
Promise.resolve({ succeeded: true, timedOut: false, jobGone: false }),
1000,
);
expect(result).toEqual({ succeeded: true, timedOut: false, jobGone: false });
});
it("returns timedOut result when grace expires first", async () => {
const { completionWithGrace } = await import("./execute.js");
vi.useFakeTimers();
try {
const slowCompletion = new Promise<{ succeeded: boolean; timedOut: boolean; jobGone: boolean }>(() => {});
const racePromise = completionWithGrace(slowCompletion, 50);
await vi.advanceTimersByTimeAsync(60);
const result = await racePromise;
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
} finally {
vi.useRealTimers();
}
});
it("returns timedOut result when completion promise rejects", async () => {
const { completionWithGrace } = await import("./execute.js");
const result = await completionWithGrace(Promise.reject(new Error("boom")), 1000);
expect(result).toEqual({ succeeded: false, timedOut: true, jobGone: false });
});
});
describe("execute — config edge paths", () => {
it("logs a warning but continues when instructionsFilePath cannot be read", async () => {
const ctx = makeCtx({ instructionsFilePath: "/does/not/exist/AGENTS.md" });
const result = await execute(ctx);
expect(result.errorCode).toBeUndefined();
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const warning = logCalls.find(([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("instructionsFilePath not readable"));
expect(warning).toBeDefined();
});
it("returns k8s_job_create_failed when ensureAgentDbPvc throws (PVC create rejected)", async () => {
vi.mocked(getPvc).mockResolvedValueOnce(null);
vi.mocked(createPvc).mockRejectedValueOnce(new Error("storage class missing"));
const ctx = makeCtx({
agentDbMode: "dedicated_pvc",
agentDbStorageClass: "fast",
});
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("storage class missing");
});
it("returns k8s_job_create_failed when ensureAgentDbPvc throws because storage class is missing", async () => {
vi.mocked(getPvc).mockResolvedValueOnce(null);
const ctx = makeCtx({ agentDbMode: "dedicated_pvc" });
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("agentDbStorageClass is required");
});
});
describe("execute — large-prompt Secret create failure", () => {
const LARGE_PROMPT = "y".repeat(300 * 1024);
it("returns k8s_job_create_failed when createNamespacedSecret throws", async () => {
vi.mocked(buildJobManifest).mockReturnValue({
job: MOCK_JOB as ReturnType<typeof buildJobManifest>["job"],
jobName: JOB_NAME,
namespace: NAMESPACE,
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
} as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi();
coreApi.createNamespacedSecret.mockRejectedValue(new Error("etcd full"));
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
const result = await execute(ctx);
expect(result.errorCode).toBe("k8s_job_create_failed");
expect(result.errorMessage).toContain("Failed to create prompt Secret");
expect(result.errorMessage).toContain("etcd full");
});
});
describe("ensureAgentDbPvc — verification failure (FAR-85 belt-and-suspenders)", () => {
it("throws when getPvc returns null after createPvc resolved (verification failed)", async () => {
vi.mocked(getPvc)
.mockResolvedValueOnce(null) // first existence check: not found
.mockResolvedValueOnce(null); // post-create verification: still not found
vi.mocked(createPvc).mockResolvedValueOnce({} as never);
await expect(
ensureAgentDbPvc("agent-x", "ns-x", { agentDbMode: "dedicated_pvc", agentDbStorageClass: "fast" }),
).rejects.toThrow(/PVC opencode-db-agent-x was not created/);
});
});
describe("execute — step limit detection", () => {
it("logs that the step limit was reached when a step_finish event has reason=max_steps", async () => {
const STEP_LIMIT_JSONL = [
JSON.stringify({ type: "text", part: { text: "partial" }, sessionID: "ses_step" }),
JSON.stringify({ type: "step_finish", part: { reason: "max_steps", tokens: { input: 10, output: 5 }, cost: 0 } }),
].join("\n");
const coreApi = makeCoreApi(STEP_LIMIT_JSONL, 0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
await execute(ctx);
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const limitLog = logCalls.find(
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("step limit reached"),
);
expect(limitLog).toBeDefined();
});
});
describe("execute — waitForPod 'no pod yet' messaging", () => {
it("emits a 'Waiting for Job controller to create pod' log when pod is not yet present", async () => {
const coreApi = makeCoreApi();
// First listNamespacedPod call returns empty (no pod yet), second returns Running
coreApi.listNamespacedPod = vi.fn()
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({
items: [{ metadata: { name: POD_NAME }, status: { phase: "Running" } }],
})
.mockResolvedValue({
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const ctx = makeCtx();
await execute(ctx);
const logCalls = vi.mocked(ctx.onLog).mock.calls;
const waitLog = logCalls.find(
([_kind, msg]: [string, string]) => typeof msg === "string" && msg.includes("Waiting for Job controller to create pod"),
);
expect(waitLog).toBeDefined();
});
});
describe("execute — pod scheduling failure (extra paths)", () => {
it("returns k8s_pod_schedule_failed when init container is in ImagePullBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { waiting: { reason: "ImagePullBackOff", message: "back-off" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/Init container.*image pull failed/);
});
it("returns k8s_pod_schedule_failed when init container is in CrashLoopBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/Init container.*crash loop/);
});
it("returns k8s_pod_schedule_failed when main container is in CrashLoopBackOff", async () => {
const coreApi = {
listNamespacedPod: vi.fn().mockResolvedValue({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
containerStatuses: [
{ name: "opencode", state: { waiting: { reason: "CrashLoopBackOff", message: "loop" } } },
],
},
},
],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(""),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBe("k8s_pod_schedule_failed");
expect(result.errorMessage).toMatch(/crash loop/);
});
it("proceeds when all init containers terminated successfully and main is running", async () => {
const coreApi = {
listNamespacedPod: vi.fn()
.mockResolvedValueOnce({
items: [
{
metadata: { name: POD_NAME },
status: {
phase: "Pending",
initContainerStatuses: [
{ name: "write-prompt", state: { terminated: { exitCode: 0 } } },
],
containerStatuses: [{ name: "opencode", state: { running: {} } }],
},
},
],
})
.mockResolvedValue({
items: [{ status: { containerStatuses: [{ name: "opencode", state: { terminated: { exitCode: 0 } } }] } }],
}),
readNamespacedPodLog: vi.fn().mockResolvedValue(HAPPY_JSONL),
};
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await execute(makeCtx());
expect(result.errorCode).toBeUndefined();
expect(result.exitCode).toBe(0);
});
});
describe("execute — skill bundle source loading", () => {
it("reads SKILL.md from entry.source dir and bundles content into the prompt", async () => {
const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-test-"));
const skillDir = path.join(tmpDir, "skill-a");
mkdirSync(skillDir);
writeFileSync(path.join(skillDir, "SKILL.md"), "skill A content");
const utils = await import("@paperclipai/adapter-utils/server-utils");
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
{ key: "paperclip/skill-a", runtimeName: "skill-a", source: skillDir, required: true } as never,
]);
const ctx = makeCtx();
await execute(ctx);
// buildJobManifest should have received the skills bundle content
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
expect(buildArgs.skillsBundleContent).toContain("skill A content");
});
it("falls back to reading entry.source as a file when SKILL.md path read throws", async () => {
const { mkdtempSync, writeFileSync } = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "skills-flat-"));
const skillFile = path.join(tmpDir, "skill-b.md");
writeFileSync(skillFile, "skill B flat content");
const utils = await import("@paperclipai/adapter-utils/server-utils");
vi.mocked(utils.readPaperclipRuntimeSkillEntries).mockResolvedValueOnce([
{ key: "paperclip/skill-b", runtimeName: "skill-b", source: skillFile, required: true } as never,
]);
const ctx = makeCtx();
await execute(ctx);
const buildArgs = vi.mocked(buildJobManifest).mock.calls[0][0];
expect(buildArgs.skillsBundleContent).toContain("skill B flat content");
});
});
describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
it("invoking the captured SIGTERM handler deletes tracked Jobs and Secrets", async () => {
// Force a fresh module so sigtermHandlerInstalled starts false again.
vi.resetModules();
vi.doMock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn().mockResolvedValue(MOCK_SELF_POD),
getBatchApi: vi.fn(),
getCoreApi: vi.fn(),
getLogApi: vi.fn(),
getPvc: vi.fn().mockResolvedValue({ metadata: { name: "opencode-db-x" } }),
createPvc: vi.fn().mockResolvedValue({}),
}));
vi.doMock("./job-manifest.js", () => ({
buildJobManifest: vi.fn().mockReturnValue({
job: MOCK_JOB,
jobName: "fresh-job",
namespace: NAMESPACE,
prompt: "p",
opencodeArgs: [],
promptMetrics: null,
}),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
const fresh = await import("./execute.js");
const k8s = await import("./k8s-client.js");
const batchApi = makeBatchApi();
const coreApi = makeCoreApi();
const logApi = makeLogApi();
vi.mocked(k8s.getBatchApi).mockReturnValue(batchApi as unknown as ReturnType<typeof k8s.getBatchApi>);
vi.mocked(k8s.getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof k8s.getCoreApi>);
vi.mocked(k8s.getLogApi).mockReturnValue(logApi as unknown as ReturnType<typeof k8s.getLogApi>);
let capturedHandler: (() => void) | null = null;
const onceSpy = vi.spyOn(process, "once").mockImplementation(
(event: string | symbol, handler: (...args: unknown[]) => void) => {
if (event === "SIGTERM") capturedHandler = handler as () => void;
return process;
},
);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as never);
await fresh.execute(makeCtx());
onceSpy.mockRestore();
expect(capturedHandler).not.toBeNull();
(capturedHandler as unknown as () => void)();
// Wait long enough for the async handler body to settle
await new Promise((r) => setTimeout(r, 50));
expect(batchApi.deleteNamespacedJob).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalled();
exitSpy.mockRestore();
vi.doUnmock("./k8s-client.js");
vi.doUnmock("./job-manifest.js");
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ export function isK8s404(err: unknown): boolean {
return false;
}
function parseModelProvider(model: string | null): string | null {
export function parseModelProvider(model: string | null): string | null {
if (!model) return null;
const trimmed = model.trim();
if (!trimmed.includes("/")) return null;
+107
View File
@@ -406,3 +406,110 @@ describe("sanitizeLabelValue", () => {
expect(warned.length).toBe(1);
});
});
describe("buildJobManifest — env wiring branches", () => {
it("sets PAPERCLIP_WAKE_PAYLOAD_JSON when paperclipWake is provided", () => {
const ctx = { ...mockCtx, context: { ...mockCtx.context, paperclipWake: { reason: "issue_assigned", issue: { id: "x" } } } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WAKE_PAYLOAD_JSON")?.value).toBeTruthy();
});
it("forwards workspace context and AGENT_HOME from paperclipWorkspace", () => {
const ctx = {
...mockCtx,
context: {
...mockCtx.context,
paperclipWorkspace: {
cwd: "/work",
source: "main",
strategy: "shared",
workspaceId: "ws_1",
repoUrl: "https://example.com/r.git",
repoRef: "main",
branchName: "feature/x",
worktreePath: "/wt/x",
agentHome: "/home/agent",
},
},
};
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_CWD")?.value).toBe("/work");
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACE_BRANCH")?.value).toBe("feature/x");
expect(env.find((e) => e.name === "AGENT_HOME")?.value).toBe("/home/agent");
});
it("sets PAPERCLIP_LINKED_ISSUE_IDS from non-empty issueIds array (skipping blanks)", () => {
const ctx = { ...mockCtx, context: { ...mockCtx.context, issueIds: ["a", " ", "b", null as unknown as string, "c"] } };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_LINKED_ISSUE_IDS")?.value).toBe("a,b,c");
});
it("encodes paperclipWorkspaces / paperclipRuntimeServiceIntents / paperclipRuntimeServices as JSON env", () => {
const ctx = {
...mockCtx,
context: {
...mockCtx.context,
paperclipWorkspaces: [{ id: "w1" }],
paperclipRuntimeServiceIntents: [{ name: "redis" }],
paperclipRuntimeServices: [{ name: "redis", url: "redis://r" }],
paperclipRuntimePrimaryUrl: "https://primary",
},
};
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_WORKSPACES_JSON")?.value).toContain("w1");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON")?.value).toContain("redis");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_SERVICES_JSON")?.value).toContain("redis://r");
expect(env.find((e) => e.name === "PAPERCLIP_RUNTIME_PRIMARY_URL")?.value).toBe("https://primary");
});
it("sets PAPERCLIP_API_KEY from ctx.authToken when provided", () => {
const ctx = { ...mockCtx, authToken: "tok_abc" };
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_API_KEY")?.value).toBe("tok_abc");
});
it("inherits PAPERCLIP_API_URL and PAPERCLIP_DEV_API_KEY from selfPod inheritedEnv", () => {
const selfPod = {
...mockSelfPod,
inheritedEnv: { PAPERCLIP_API_URL: "http://api", PAPERCLIP_DEV_API_KEY: "dev_key" },
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const env = result.job.spec?.template.spec?.containers[0]?.env ?? [];
expect(env.find((e) => e.name === "PAPERCLIP_API_URL")?.value).toBe("http://api");
expect(env.find((e) => e.name === "PAPERCLIP_DEV_API_KEY")?.value).toBe("dev_key");
});
});
describe("buildJobManifest — volume wiring branches", () => {
it("mounts the prompt secret volume when promptSecretName is provided", () => {
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, promptSecretName: "prompt-x" });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "prompt-secret")?.secret?.secretName).toBe("prompt-x");
});
it("mounts the data PVC at /paperclip when selfPod has a pvcClaimName", () => {
const selfPod = { ...mockSelfPod, pvcClaimName: "paperclip-data" };
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "data")?.persistentVolumeClaim?.claimName).toBe("paperclip-data");
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
expect(mounts.find((m) => m.name === "data")?.mountPath).toBe("/paperclip");
});
it("mounts inherited secret volumes from selfPod.secretVolumes", () => {
const selfPod = {
...mockSelfPod,
secretVolumes: [{ volumeName: "tls", secretName: "tls-secret", mountPath: "/etc/tls", defaultMode: 0o400 }],
};
const result = buildJobManifest({ ctx: mockCtx, selfPod });
const volumes = result.job.spec?.template.spec?.volumes ?? [];
expect(volumes.find((v) => v.name === "tls")?.secret?.secretName).toBe("tls-secret");
const mounts = result.job.spec?.template.spec?.containers[0]?.volumeMounts ?? [];
expect(mounts.find((m) => m.name === "tls")).toEqual({ name: "tls", mountPath: "/etc/tls", readOnly: true });
});
});
+154 -3
View File
@@ -28,13 +28,14 @@ vi.mock("@kubernetes/client-node", () => {
}
}
class KubeConfig {
loadFromCluster() {}
loadFromFile() {}
loadFromCluster = mockLoadFromCluster;
loadFromFile = mockLoadFromFile;
makeApiClient() {
return {
readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC,
deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC,
createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC,
readNamespacedPod: mockReadNamespacedPod,
};
}
}
@@ -51,9 +52,17 @@ vi.mock("@kubernetes/client-node", () => {
const mockReadNamespacedPVC = vi.fn();
const mockDeleteNamespacedPVC = vi.fn();
const mockCreateNamespacedPVC = vi.fn();
const mockReadNamespacedPod = vi.fn();
const mockLoadFromCluster = vi.fn();
const mockLoadFromFile = vi.fn();
const mockReadFileSync = vi.fn();
vi.mock("node:fs", () => ({
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
}));
import * as k8s from "@kubernetes/client-node";
import { getPvc, createPvc, deletePvc, resetCache } from "./k8s-client.js";
import { getPvc, createPvc, deletePvc, getSelfPodInfo, resetCache } from "./k8s-client.js";
const ApiException = (k8s as unknown as { ApiException: new <T>(code: number, message: string, body: T, headers?: Record<string, string>) => Error & { code: number; body: T } }).ApiException;
@@ -143,3 +152,145 @@ describe("createPvc — passes through to SDK", () => {
expect(mockCreateNamespacedPVC).toHaveBeenCalledWith({ namespace: "paperclip", body: spec });
});
});
describe("getSelfPodInfo", () => {
const HOSTNAME = "paperclip-test-pod";
const NAMESPACE = "paperclip-test";
beforeEach(() => {
process.env.HOSTNAME = HOSTNAME;
delete process.env.PAPERCLIP_NAMESPACE;
delete process.env.POD_NAMESPACE;
mockReadFileSync.mockReturnValue(NAMESPACE);
});
function basePod(overrides: Record<string, unknown> = {}) {
return {
spec: {
containers: [
{
name: "paperclip",
image: "paperclip:1.0",
env: [
{ name: "FOO", value: "bar" },
{ name: "SECRET_REF", valueFrom: { secretKeyRef: { name: "s", key: "k" } } },
],
envFrom: [{ configMapRef: { name: "cm" } }],
volumeMounts: [
{ name: "data", mountPath: "/paperclip" },
{ name: "tls-secret", mountPath: "/etc/tls" },
],
},
],
volumes: [
{ name: "data", persistentVolumeClaim: { claimName: "paperclip-pvc" } },
{ name: "tls-secret", secret: { secretName: "tls", defaultMode: 0o400 } },
],
imagePullSecrets: [{ name: "registry-creds" }, { name: "" }, {}],
dnsConfig: { nameservers: ["10.0.0.10"] },
...overrides,
},
};
}
it("introspects the pod and extracts image, env, PVC, secrets, dnsConfig", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe(NAMESPACE);
expect(info.image).toBe("paperclip:1.0");
expect(info.pvcClaimName).toBe("paperclip-pvc");
expect(info.inheritedEnv).toEqual({ FOO: "bar" });
expect(info.inheritedEnvValueFrom).toHaveLength(1);
expect(info.inheritedEnvValueFrom[0].name).toBe("SECRET_REF");
expect(info.inheritedEnvFrom).toHaveLength(1);
expect(info.secretVolumes).toEqual([
{ volumeName: "tls-secret", secretName: "tls", mountPath: "/etc/tls", defaultMode: 0o400 },
]);
// imagePullSecrets with empty name are filtered out
expect(info.imagePullSecrets).toEqual([{ name: "registry-creds" }]);
expect(info.dnsConfig).toEqual({ nameservers: ["10.0.0.10"] });
expect(mockReadNamespacedPod).toHaveBeenCalledWith({ name: HOSTNAME, namespace: NAMESPACE });
});
it("caches the result — second call does not re-query the API", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
await getSelfPodInfo();
await getSelfPodInfo();
expect(mockReadNamespacedPod).toHaveBeenCalledTimes(1);
});
it("prefers PAPERCLIP_NAMESPACE env over service-account file", async () => {
process.env.PAPERCLIP_NAMESPACE = "from-env";
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("from-env");
expect(mockReadFileSync).not.toHaveBeenCalled();
});
it("falls back to POD_NAMESPACE when PAPERCLIP_NAMESPACE not set", async () => {
process.env.POD_NAMESPACE = "downward-api";
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("downward-api");
});
it("falls back to 'default' when service-account file read throws", async () => {
mockReadFileSync.mockImplementation(() => {
throw new Error("ENOENT");
});
mockReadNamespacedPod.mockResolvedValue(basePod());
const info = await getSelfPodInfo();
expect(info.namespace).toBe("default");
});
it("throws when HOSTNAME is not set", async () => {
delete process.env.HOSTNAME;
await expect(getSelfPodInfo()).rejects.toThrow("HOSTNAME env var not set");
});
it("throws when pod has no spec", async () => {
mockReadNamespacedPod.mockResolvedValue({ spec: null });
await expect(getSelfPodInfo()).rejects.toThrow("has no spec");
});
it("throws when main container has no image", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "paperclip", image: "" }] },
});
await expect(getSelfPodInfo()).rejects.toThrow("has no container image");
});
it("falls back to first container when no container is named 'paperclip'", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "other", image: "other:1.0" }] },
});
const info = await getSelfPodInfo();
expect(info.image).toBe("other:1.0");
});
it("returns null pvcClaimName when no /paperclip mount exists", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: { containers: [{ name: "paperclip", image: "p:1", volumeMounts: [] }] },
});
const info = await getSelfPodInfo();
expect(info.pvcClaimName).toBeNull();
});
it("returns null pvcClaimName when /paperclip mount is not backed by a PVC", async () => {
mockReadNamespacedPod.mockResolvedValue({
spec: {
containers: [{ name: "paperclip", image: "p:1", volumeMounts: [{ name: "data", mountPath: "/paperclip" }] }],
volumes: [{ name: "data", emptyDir: {} }],
},
});
const info = await getSelfPodInfo();
expect(info.pvcClaimName).toBeNull();
});
it("uses kubeconfig file path when provided (not in-cluster)", async () => {
mockReadNamespacedPod.mockResolvedValue(basePod());
await getSelfPodInfo("/tmp/kubeconfig.yaml");
expect(mockLoadFromFile).toHaveBeenCalledWith("/tmp/kubeconfig.yaml");
expect(mockLoadFromCluster).not.toHaveBeenCalled();
});
});
+42
View File
@@ -182,3 +182,45 @@ describe("isOpenCodeUnknownSessionError", () => {
expect(isOpenCodeUnknownSessionError(stdout, "")).toBe(true);
});
});
describe("parseOpenCodeJsonl — errorText fallback paths", () => {
it("uses nested data.message when top-level message is missing", () => {
const stdout = JSON.stringify({
type: "error",
error: { data: { message: "nested issue" } },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("nested issue");
});
it("uses error.name when no message or nested message", () => {
const stdout = JSON.stringify({
type: "error",
error: { name: "ProviderAuthError" },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("ProviderAuthError");
});
it("uses error.code when no message/name", () => {
const stdout = JSON.stringify({
type: "error",
error: { code: "E_TIMEOUT" },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("E_TIMEOUT");
});
it("falls back to JSON.stringify of the error object when nothing matches", () => {
const stdout = JSON.stringify({
type: "error",
error: { unexpectedShape: { foo: "bar" } },
sessionID: "ses_x",
});
const result = parseOpenCodeJsonl(stdout);
expect(result.errorMessage).toContain("unexpectedShape");
});
});
+38
View File
@@ -323,3 +323,41 @@ describe("parseStdoutLine", () => {
expect(parseStdoutLine(line, TS)).toEqual([]);
});
});
describe("parseStdoutLine — error edge cases", () => {
const TS_ERR = "2026-04-25T22:00:00.000Z";
it("returns stdout entry when JSON parses to a primitive (not an object)", () => {
const result = parseStdoutLine("42", TS_ERR);
// safeJsonParse returns null for non-object → falls through to stdout entry
expect(result).toEqual([{ kind: "stdout", ts: TS_ERR, text: "42" }]);
});
it("returns empty for text event with empty text", () => {
const line = JSON.stringify({ type: "text", part: { text: "" } });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("returns empty for assistant event with no content blocks", () => {
const line = JSON.stringify({ type: "assistant", part: { message: { content: null } } });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("returns empty for error event whose error field is an empty string", () => {
const line = JSON.stringify({ type: "error", error: "" });
expect(parseStdoutLine(line, TS_ERR)).toEqual([]);
});
it("uses error.code fallback when error has no message/data/name", () => {
const line = JSON.stringify({ type: "error", error: { code: "E_FOO" } });
const result = parseStdoutLine(line, TS_ERR);
expect(result).toEqual([{ kind: "stderr", ts: TS_ERR, text: "E_FOO" }]);
});
it("falls back to JSON.stringify of error object when no known field", () => {
const line = JSON.stringify({ type: "error", error: { somethingElse: "x" } });
const result = parseStdoutLine(line, TS_ERR);
expect(result[0].kind).toBe("stderr");
expect((result[0] as { text: string }).text).toContain("somethingElse");
});
});