798b80f2f2
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>
516 lines
22 KiB
TypeScript
516 lines
22 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { buildJobManifest, sanitizeLabelValue, type JobBuildInput } from "./job-manifest.js";
|
|
|
|
const mockSelfPod: JobBuildInput["selfPod"] = {
|
|
namespace: "paperclip",
|
|
image: "paperclip/paperclip:latest",
|
|
imagePullSecrets: [],
|
|
inheritedEnv: {},
|
|
inheritedEnvValueFrom: [],
|
|
inheritedEnvFrom: [],
|
|
pvcClaimName: null,
|
|
dnsConfig: undefined,
|
|
secretVolumes: [],
|
|
};
|
|
|
|
const mockCtx: JobBuildInput["ctx"] = {
|
|
runId: "run123456",
|
|
agent: { id: "agent-abc", name: "Test Agent", companyId: "co123", adapterType: null, adapterConfig: null },
|
|
runtime: { sessionId: null, sessionParams: {}, sessionDisplayId: null, taskKey: null },
|
|
config: {},
|
|
context: {
|
|
taskId: null,
|
|
issueId: null,
|
|
paperclipWorkspace: null,
|
|
issueIds: null,
|
|
paperclipWorkspaces: null,
|
|
paperclipRuntimeServiceIntents: null,
|
|
paperclipRuntimeServices: null,
|
|
},
|
|
onLog: async () => {},
|
|
};
|
|
|
|
describe("buildJobManifest", () => {
|
|
it("creates job with agent-opencode- prefix in name", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.jobName).toMatch(/^agent-opencode-/);
|
|
});
|
|
|
|
it("uses default image from selfPod", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const container = result.job.spec?.template?.spec?.containers?.[0];
|
|
expect(container?.image).toBe("paperclip/paperclip:latest");
|
|
});
|
|
|
|
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const securityContext = result.job.spec?.template?.spec?.securityContext;
|
|
expect(securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch");
|
|
});
|
|
|
|
it("sets runAsNonRoot and runAsUser 1000", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const securityContext = result.job.spec?.template?.spec?.securityContext;
|
|
expect(securityContext?.runAsNonRoot).toBe(true);
|
|
expect(securityContext?.runAsUser).toBe(1000);
|
|
expect(securityContext?.runAsGroup).toBe(1000);
|
|
expect(securityContext?.fsGroup).toBe(1000);
|
|
});
|
|
|
|
it("maps labels to job metadata", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip");
|
|
expect(result.job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("opencode_k8s");
|
|
expect(result.job.metadata?.labels?.["paperclip.io/agent-id"]).toBe("agent-abc");
|
|
expect(result.job.metadata?.labels?.["paperclip.io/run-id"]).toBe("run123456");
|
|
});
|
|
|
|
it("sets paperclip.io/task-id label when context.taskId is present", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
context: { ...mockCtx.context, taskId: "7e0829c0-cbf7-4652-9554-4d777ce84bff" },
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("7e0829c0-cbf7-4652-9554-4d777ce84bff");
|
|
});
|
|
|
|
it("falls back to context.issueId for paperclip.io/task-id when taskId is null", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
context: { ...mockCtx.context, taskId: null, issueId: "issue-uuid-xyz" },
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-uuid-xyz");
|
|
});
|
|
|
|
it("omits paperclip.io/task-id label when context.taskId and issueId are both null", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
|
|
});
|
|
|
|
it("sets paperclip.io/session-id label when runtime.sessionParams.sessionId is present", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
runtime: { ...mockCtx.runtime, sessionParams: { sessionId: "ses_abc123" } },
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBe("ses_abc123");
|
|
});
|
|
|
|
it("omits paperclip.io/session-id label when no session exists", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
|
|
});
|
|
|
|
it("creates init container for prompt", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const initContainers = result.job.spec?.template?.spec?.initContainers;
|
|
expect(initContainers?.length).toBe(1);
|
|
expect(initContainers?.[0].name).toBe("write-prompt");
|
|
expect(initContainers?.[0].image).toBe("busybox:1.36");
|
|
});
|
|
|
|
it("sets HOME to /paperclip", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
const homeEnv = env.find((e) => e.name === "HOME");
|
|
expect(homeEnv?.value).toBe("/paperclip");
|
|
});
|
|
|
|
it("sets OPENCODE_DISABLE_PROJECT_CONFIG=true", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
const opencodeEnv = env.find((e) => e.name === "OPENCODE_DISABLE_PROJECT_CONFIG");
|
|
expect(opencodeEnv?.value).toBe("true");
|
|
});
|
|
|
|
it("applies default ttlSecondsAfterFinished of 300", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.ttlSecondsAfterFinished).toBe(300);
|
|
});
|
|
|
|
it("sets backoffLimit to 0", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.backoffLimit).toBe(0);
|
|
});
|
|
|
|
it("uses job template restartPolicy Never", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.template?.spec?.restartPolicy).toBe("Never");
|
|
});
|
|
|
|
it("applies nodeSelector from key=value textarea string", () => {
|
|
const ctx = { ...mockCtx, config: { nodeSelector: "kubernetes.io/arch=amd64\nkubernetes.io/os=linux" } };
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({
|
|
"kubernetes.io/arch": "amd64",
|
|
"kubernetes.io/os": "linux",
|
|
});
|
|
});
|
|
|
|
it("applies nodeSelector from JSON object string", () => {
|
|
const ctx = { ...mockCtx, config: { nodeSelector: '{"node-type":"gpu"}' } };
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "node-type": "gpu" });
|
|
});
|
|
|
|
it("applies nodeSelector from plain object config", () => {
|
|
const ctx = { ...mockCtx, config: { nodeSelector: { "zone": "us-east-1" } } };
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ zone: "us-east-1" });
|
|
});
|
|
|
|
it("ignores blank lines and comments in nodeSelector textarea", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
config: { nodeSelector: "# comment\n\nkubernetes.io/arch=amd64\n" },
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.job.spec?.template?.spec?.nodeSelector).toEqual({ "kubernetes.io/arch": "amd64" });
|
|
});
|
|
|
|
it("forwards inheritedEnvValueFrom entries onto the opencode container env", () => {
|
|
const selfPod = {
|
|
...mockSelfPod,
|
|
inheritedEnvValueFrom: [
|
|
{ name: "MY_SECRET", valueFrom: { secretKeyRef: { name: "my-secret", key: "token" } } },
|
|
],
|
|
};
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
|
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
const secretEnv = env.find((e) => e.name === "MY_SECRET");
|
|
expect(secretEnv?.valueFrom?.secretKeyRef?.name).toBe("my-secret");
|
|
expect(secretEnv?.valueFrom?.secretKeyRef?.key).toBe("token");
|
|
});
|
|
|
|
it("does not duplicate an inheritedEnvValueFrom entry if the name is already set as a literal", () => {
|
|
const selfPod = {
|
|
...mockSelfPod,
|
|
inheritedEnv: { HOME: "/custom" },
|
|
inheritedEnvValueFrom: [
|
|
{ name: "HOME", valueFrom: { secretKeyRef: { name: "s", key: "k" } } },
|
|
],
|
|
};
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
|
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
const homeEntries = env.filter((e) => e.name === "HOME");
|
|
// HOME is overridden by merged (HOME=/paperclip hardcoded last), so valueFrom must not appear
|
|
expect(homeEntries.every((e) => e.value !== undefined)).toBe(true);
|
|
});
|
|
|
|
it("forwards inheritedEnvFrom onto the opencode container envFrom", () => {
|
|
const selfPod = {
|
|
...mockSelfPod,
|
|
inheritedEnvFrom: [{ secretRef: { name: "my-config-secret" } }],
|
|
};
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod });
|
|
|
|
const container = result.job.spec?.template?.spec?.containers?.[0];
|
|
expect(container?.envFrom).toEqual([{ secretRef: { name: "my-config-secret" } }]);
|
|
});
|
|
|
|
it("omits envFrom when inheritedEnvFrom is empty", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
const container = result.job.spec?.template?.spec?.containers?.[0];
|
|
expect(container?.envFrom).toBeUndefined();
|
|
});
|
|
|
|
it("job name includes 6-char hash suffix", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
// format: agent-opencode-{agentSlug}-{runSlug}-{6hexchars}
|
|
expect(result.jobName).toMatch(/^agent-opencode-[a-z0-9-]+-[a-f0-9]{6}$/);
|
|
});
|
|
|
|
it("job name has no trailing hyphens even when slug sanitization creates them", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
agent: { ...mockCtx.agent, id: "agent---" },
|
|
runId: "run---",
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
expect(result.jobName).not.toMatch(/-$/);
|
|
});
|
|
|
|
it("label values are sanitized to [a-z0-9._-]", () => {
|
|
const ctx = {
|
|
...mockCtx,
|
|
agent: { ...mockCtx.agent, id: "Agent_ID/123", companyId: "Co:456" },
|
|
runId: "Run@789",
|
|
};
|
|
const result = buildJobManifest({ ctx, selfPod: mockSelfPod });
|
|
|
|
const labels = result.job.metadata?.labels ?? {};
|
|
expect(labels["paperclip.io/agent-id"]).toMatch(/^[a-z0-9._-]*$/);
|
|
expect(labels["paperclip.io/run-id"]).toMatch(/^[a-z0-9._-]*$/);
|
|
expect(labels["paperclip.io/company-id"]).toMatch(/^[a-z0-9._-]*$/);
|
|
});
|
|
|
|
it("same agent+run always produces the same job name (deterministic hash)", () => {
|
|
const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
const r2 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
|
|
expect(r1.jobName).toBe(r2.jobName);
|
|
});
|
|
|
|
it("different runIds produce different job names", () => {
|
|
const ctx2 = { ...mockCtx, runId: "run-different-999" };
|
|
const r1 = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
const r2 = buildJobManifest({ ctx: ctx2, selfPod: mockSelfPod });
|
|
|
|
expect(r1.jobName).not.toBe(r2.jobName);
|
|
});
|
|
});
|
|
|
|
describe("agentDbClaimName — OPENCODE_DB env var", () => {
|
|
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is a string (dedicated PVC)", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
|
|
});
|
|
|
|
it("sets OPENCODE_DB to /opencode-db when agentDbClaimName is null (ephemeral)", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null });
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
expect(env.find((e) => e.name === "OPENCODE_DB")?.value).toBe("/opencode-db");
|
|
});
|
|
|
|
it("does not set OPENCODE_DB when agentDbClaimName is undefined", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
expect(env.find((e) => e.name === "OPENCODE_DB")).toBeUndefined();
|
|
});
|
|
|
|
it("replaces a user-provided OPENCODE_DB env override with /opencode-db", () => {
|
|
const selfPod = { ...mockSelfPod, inheritedEnv: { OPENCODE_DB: "/user/override" } };
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
|
const env = result.job.spec?.template?.spec?.containers?.[0].env ?? [];
|
|
const dbEntries = env.filter((e) => e.name === "OPENCODE_DB");
|
|
expect(dbEntries).toHaveLength(1);
|
|
expect(dbEntries[0].value).toBe("/opencode-db");
|
|
});
|
|
});
|
|
|
|
describe("agentDbClaimName — volume wiring", () => {
|
|
it("mounts dedicated PVC at /opencode-db when agentDbClaimName is a string", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
|
const dbVol = volumes.find((v) => v.name === "opencode-db");
|
|
expect(dbVol?.persistentVolumeClaim?.claimName).toBe("opencode-db-agent-abc");
|
|
|
|
const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? [];
|
|
expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db");
|
|
});
|
|
|
|
it("mounts emptyDir at /opencode-db when agentDbClaimName is null (ephemeral)", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: null });
|
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
|
const dbVol = volumes.find((v) => v.name === "opencode-db");
|
|
expect(dbVol?.emptyDir).toBeDefined();
|
|
expect(dbVol?.persistentVolumeClaim).toBeUndefined();
|
|
|
|
const mounts = result.job.spec?.template?.spec?.containers?.[0].volumeMounts ?? [];
|
|
expect(mounts.find((m) => m.name === "opencode-db")?.mountPath).toBe("/opencode-db");
|
|
});
|
|
|
|
it("does not add opencode-db volume when agentDbClaimName is undefined", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod });
|
|
const volumes = result.job.spec?.template?.spec?.volumes ?? [];
|
|
expect(volumes.find((v) => v.name === "opencode-db")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("init container is unchanged by agentDbClaimName", () => {
|
|
it("does not add mkdir or extra env vars to init container for dedicated PVC mode", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
|
const initCmd = result.job.spec?.template?.spec?.initContainers?.[0].command;
|
|
expect(initCmd?.[2]).not.toContain("mkdir");
|
|
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
|
|
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
|
|
});
|
|
|
|
it("does not add PVC mount to init container for dedicated PVC mode", () => {
|
|
const result = buildJobManifest({ ctx: mockCtx, selfPod: mockSelfPod, agentDbClaimName: "opencode-db-agent-abc" });
|
|
const initMounts = result.job.spec?.template?.spec?.initContainers?.[0].volumeMounts ?? [];
|
|
expect(initMounts.some((m) => m.name === "opencode-db")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("sanitizeLabelValue", () => {
|
|
it("passes through clean values unchanged", () => {
|
|
expect(sanitizeLabelValue("abc-123")).toBe("abc-123");
|
|
expect(sanitizeLabelValue("foo.bar_baz")).toBe("foo.bar_baz");
|
|
});
|
|
|
|
it("lowercases uppercase letters", () => {
|
|
expect(sanitizeLabelValue("MyAgent")).toBe("myagent");
|
|
});
|
|
|
|
it("strips chars outside [a-z0-9._-]", () => {
|
|
expect(sanitizeLabelValue("agent/id:test@v1")).toBe("agentidtestv1");
|
|
});
|
|
|
|
it("truncates to 63 chars", () => {
|
|
const long = "a".repeat(80);
|
|
expect(sanitizeLabelValue(long)).toHaveLength(63);
|
|
});
|
|
|
|
it("calls warn when chars are dropped", () => {
|
|
const warned: string[] = [];
|
|
sanitizeLabelValue("agent/id", (msg) => warned.push(msg));
|
|
expect(warned.length).toBe(1);
|
|
expect(warned[0]).toContain("agent/id");
|
|
});
|
|
|
|
it("does not call warn when nothing is dropped", () => {
|
|
const warned: string[] = [];
|
|
sanitizeLabelValue("clean-value.123", (msg) => warned.push(msg));
|
|
expect(warned.length).toBe(0);
|
|
});
|
|
|
|
it("does not call warn when the only change is truncation to exactly 63", () => {
|
|
const warned: string[] = [];
|
|
// 63 chars all lowercase clean — no warn expected
|
|
sanitizeLabelValue("a".repeat(63), (msg) => warned.push(msg));
|
|
expect(warned.length).toBe(0);
|
|
});
|
|
|
|
it("calls warn when value contains invalid chars even if truncated result looks clean", () => {
|
|
const warned: string[] = [];
|
|
// 'a/b' repeated — '/' is stripped, so sanitized differs from lower.slice(0,63)
|
|
sanitizeLabelValue("a/b".repeat(25), (msg) => warned.push(msg));
|
|
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 });
|
|
});
|
|
});
|