29a4e709d0
Co-Authored-By: Claude Sonnet <noreply@anthropic.com> Co-Authored-By: Paperclip <noreply@paperclip.ing>
935 lines
40 KiB
TypeScript
935 lines
40 KiB
TypeScript
import { describe, it, expect, beforeEach } from "vitest";
|
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
|
import { buildJobManifest, buildRtkSetupCommands, sanitizeLabelValue } from "./job-manifest.js";
|
|
import type { SelfPodInfo } from "./k8s-client.js";
|
|
|
|
function makeCtx(overrides: Partial<AdapterExecutionContext> = {}): AdapterExecutionContext {
|
|
return {
|
|
runId: "run-abc12345",
|
|
agent: { id: "agent-abc", companyId: "co1", name: "Test Agent", adapterType: "claude_k8s", adapterConfig: {} },
|
|
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
|
config: {},
|
|
context: {},
|
|
onLog: async () => {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeSelfPod(overrides: Partial<SelfPodInfo> = {}): SelfPodInfo {
|
|
return {
|
|
namespace: "paperclip",
|
|
image: "paperclipai/paperclip:latest",
|
|
imagePullSecrets: [{ name: "regcred" }],
|
|
dnsConfig: undefined,
|
|
pvcClaimName: "paperclip-data",
|
|
secretVolumes: [],
|
|
inheritedEnv: {},
|
|
inheritedEnvValueFrom: [],
|
|
inheritedEnvFrom: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("buildJobManifest", () => {
|
|
let ctx: AdapterExecutionContext;
|
|
let selfPod: SelfPodInfo;
|
|
|
|
beforeEach(() => {
|
|
ctx = makeCtx();
|
|
selfPod = makeSelfPod();
|
|
});
|
|
|
|
describe("job naming", () => {
|
|
it("uses ac- prefix", () => {
|
|
const { jobName } = buildJobManifest({ ctx, selfPod });
|
|
expect(jobName).toMatch(/^ac-/);
|
|
});
|
|
|
|
it("includes sanitized agent id slug (up to 16 chars)", () => {
|
|
ctx.agent.id = "Agent-ABC!@#";
|
|
const { jobName } = buildJobManifest({ ctx, selfPod });
|
|
// sanitizeForK8sName: lowercase, strip non-alphanumeric (not dashes), slice 0-16
|
|
expect(jobName).toContain("agent-abc");
|
|
});
|
|
|
|
it("includes sanitized run id slug (up to 16 chars)", () => {
|
|
ctx.runId = "RUN-ABC-12345";
|
|
const { jobName } = buildJobManifest({ ctx, selfPod });
|
|
expect(jobName).toContain("run-abc-12345");
|
|
});
|
|
|
|
it("includes a deterministic hash suffix", () => {
|
|
const result1 = buildJobManifest({ ctx, selfPod });
|
|
const result2 = buildJobManifest({ ctx, selfPod });
|
|
expect(result1.jobName).toBe(result2.jobName);
|
|
// Hash suffix is 6 hex chars at the end
|
|
expect(result1.jobName).toMatch(/-[0-9a-f]{6}$/);
|
|
});
|
|
|
|
it("different agent+run pairs produce different names", () => {
|
|
const result1 = buildJobManifest({ ctx, selfPod });
|
|
ctx.runId = "run-different";
|
|
const result2 = buildJobManifest({ ctx, selfPod });
|
|
expect(result1.jobName).not.toBe(result2.jobName);
|
|
});
|
|
|
|
it("stays within 63-char DNS label limit", () => {
|
|
ctx.agent.id = "a".repeat(100);
|
|
ctx.runId = "r".repeat(100);
|
|
const { jobName } = buildJobManifest({ ctx, selfPod });
|
|
expect(jobName.length).toBeLessThanOrEqual(63);
|
|
});
|
|
});
|
|
|
|
describe("job spec", () => {
|
|
it("sets backoffLimit to 0 for fail-fast", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.backoffLimit).toBe(0);
|
|
});
|
|
|
|
it("sets activeDeadlineSeconds when timeoutSec > 0", () => {
|
|
ctx.config = { timeoutSec: 300 };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.activeDeadlineSeconds).toBe(300);
|
|
});
|
|
|
|
it("omits activeDeadlineSeconds when timeoutSec is 0", () => {
|
|
ctx.config = { timeoutSec: 0 };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.activeDeadlineSeconds).toBeUndefined();
|
|
});
|
|
|
|
it("sets ttlSecondsAfterFinished default 300", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.ttlSecondsAfterFinished).toBe(300);
|
|
});
|
|
|
|
it("uses configured ttlSecondsAfterFinished", () => {
|
|
ctx.config = { ttlSecondsAfterFinished: 600 };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.ttlSecondsAfterFinished).toBe(600);
|
|
});
|
|
});
|
|
|
|
describe("labels", () => {
|
|
it("includes required paperclip labels", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const labels = job.metadata?.labels ?? {};
|
|
expect(labels["app.kubernetes.io/managed-by"]).toBe("paperclip");
|
|
expect(labels["app.kubernetes.io/component"]).toBe("agent-job");
|
|
expect(labels["paperclip.io/agent-id"]).toBe("agent-abc");
|
|
expect(labels["paperclip.io/run-id"]).toBe("run-abc12345");
|
|
expect(labels["paperclip.io/company-id"]).toBe("co1");
|
|
expect(labels["paperclip.io/adapter-type"]).toBe("claude_k8s");
|
|
});
|
|
|
|
it("includes extra labels from config", () => {
|
|
ctx.config = { labels: { "env": "prod", "team": "platform" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.env).toBe("prod");
|
|
expect(job.metadata?.labels?.team).toBe("platform");
|
|
});
|
|
|
|
it("merges extra labels with required ones", () => {
|
|
ctx.config = { labels: { "env": "prod" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.env).toBe("prod");
|
|
expect(job.metadata?.labels?.["paperclip.io/adapter-type"]).toBe("claude_k8s");
|
|
});
|
|
|
|
it("adds task-id label when context provides taskId", () => {
|
|
ctx.context = { taskId: "task-xyz-789" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("task-xyz-789");
|
|
});
|
|
|
|
it("falls back to issueId when taskId absent", () => {
|
|
ctx.context = { issueId: "issue-42" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBe("issue-42");
|
|
});
|
|
|
|
it("adds session-id label when runtime provides sessionId", () => {
|
|
ctx.runtime = { ...ctx.runtime, sessionId: "sess-abc-1234" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-abc-1234");
|
|
});
|
|
|
|
it("reads sessionId from runtime.sessionParams when sessionId prop missing", () => {
|
|
ctx.runtime = { ...ctx.runtime, sessionParams: { sessionId: "sess-from-params" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBe("sess-from-params");
|
|
});
|
|
|
|
it("omits task-id and session-id labels when neither is provided", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/task-id"]).toBeUndefined();
|
|
expect(job.metadata?.labels?.["paperclip.io/session-id"]).toBeUndefined();
|
|
});
|
|
|
|
it("drops user label with paperclip.io/ prefix", () => {
|
|
ctx.config = { labels: { "paperclip.io/run-id": "hijacked" } };
|
|
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/run-id"]).not.toBe("hijacked");
|
|
expect(skippedLabels).toContain("paperclip.io/run-id");
|
|
});
|
|
|
|
it("drops user label with app.kubernetes.io/ prefix", () => {
|
|
ctx.config = { labels: { "app.kubernetes.io/managed-by": "attacker" } };
|
|
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["app.kubernetes.io/managed-by"]).toBe("paperclip");
|
|
expect(skippedLabels).toContain("app.kubernetes.io/managed-by");
|
|
});
|
|
|
|
it("passes through user label without reserved prefix", () => {
|
|
ctx.config = { labels: { "custom.io/team": "platform" } };
|
|
const { job, skippedLabels } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.["custom.io/team"]).toBe("platform");
|
|
expect(skippedLabels).not.toContain("custom.io/team");
|
|
});
|
|
|
|
it("populates skippedLabels with all dropped keys", () => {
|
|
ctx.config = {
|
|
labels: {
|
|
"paperclip.io/agent-id": "x",
|
|
"app.kubernetes.io/component": "y",
|
|
"safe": "z",
|
|
},
|
|
};
|
|
const { skippedLabels } = buildJobManifest({ ctx, selfPod });
|
|
expect(skippedLabels).toHaveLength(2);
|
|
expect(skippedLabels).toContain("paperclip.io/agent-id");
|
|
expect(skippedLabels).toContain("app.kubernetes.io/component");
|
|
});
|
|
});
|
|
|
|
describe("system label sanitization (N4)", () => {
|
|
it("sanitizes agent.id with @ to a valid RFC 1123 label", () => {
|
|
ctx.agent.id = "user@example.com";
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
|
|
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
|
|
expect(label).not.toContain("@");
|
|
});
|
|
|
|
it("sanitizes agent.id with spaces to a valid RFC 1123 label", () => {
|
|
ctx.agent.id = "my agent id";
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const label = job.metadata?.labels?.["paperclip.io/agent-id"];
|
|
expect(label).toMatch(/^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$/);
|
|
});
|
|
|
|
it("omits paperclip.io/run-id when sanitized value is null (all-invalid runId)", () => {
|
|
// inject an all-special-chars runId via context override — buildJobManifest
|
|
// uses ctx.runId directly
|
|
const badCtx = makeCtx({ runId: "@@@" });
|
|
const { job, skippedLabels } = buildJobManifest({ ctx: badCtx, selfPod });
|
|
expect(job.metadata?.labels?.["paperclip.io/run-id"]).toBeUndefined();
|
|
expect(skippedLabels).toContain("paperclip.io/run-id");
|
|
});
|
|
|
|
it("selector matches sanitized agent-id label", () => {
|
|
ctx.agent.id = "Agent@Test";
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const agentLabel = job.metadata?.labels?.["paperclip.io/agent-id"];
|
|
// the label should equal what sanitizeLabelValue produces
|
|
expect(agentLabel).toBe("AgentTest");
|
|
});
|
|
});
|
|
|
|
describe("annotations", () => {
|
|
it("includes adapter type and agent name annotations", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.annotations?.["paperclip.io/adapter-type"]).toBe("claude_k8s");
|
|
expect(job.metadata?.annotations?.["paperclip.io/agent-name"]).toBe("Test Agent");
|
|
});
|
|
});
|
|
|
|
describe("pod spec", () => {
|
|
it("sets restartPolicy to Never", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.restartPolicy).toBe("Never");
|
|
});
|
|
|
|
it("sets fsGroupChangePolicy to OnRootMismatch", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.securityContext?.fsGroupChangePolicy).toBe("OnRootMismatch");
|
|
});
|
|
|
|
it("sets fsGroup, runAsNonRoot, runAsUser, runAsGroup", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const sc = job.spec?.template?.spec?.securityContext;
|
|
expect(sc?.runAsNonRoot).toBe(true);
|
|
expect(sc?.runAsUser).toBe(1000);
|
|
expect(sc?.runAsGroup).toBe(1000);
|
|
expect(sc?.fsGroup).toBe(1000);
|
|
});
|
|
|
|
it("includes imagePullSecrets from selfPod", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.imagePullSecrets).toEqual([{ name: "regcred" }]);
|
|
});
|
|
|
|
it("omits imagePullSecrets when empty", () => {
|
|
selfPod.imagePullSecrets = [];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.imagePullSecrets).toBeUndefined();
|
|
});
|
|
|
|
it("includes dnsConfig from selfPod when present", () => {
|
|
selfPod.dnsConfig = { nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.dnsConfig).toEqual({ nameservers: ["8.8.8.8"], searches: ["svc.cluster.local"] });
|
|
});
|
|
|
|
it("omits dnsConfig when not present", () => {
|
|
selfPod.dnsConfig = undefined;
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.dnsConfig).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("init containers", () => {
|
|
it("has write-prompt init container with busybox image", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
expect(init?.name).toBe("write-prompt");
|
|
expect(init?.image).toBe("busybox:1.36");
|
|
expect(init?.imagePullPolicy).toBe("IfNotPresent");
|
|
});
|
|
|
|
it("write-prompt writes PROMPT_CONTENT to /tmp/prompt/prompt.txt", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
expect(init?.command).toEqual(["sh", "-c", "printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"]);
|
|
});
|
|
|
|
it("write-prompt mounts prompt volume", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
expect(init?.volumeMounts).toContainEqual({ name: "prompt", mountPath: "/tmp/prompt" });
|
|
});
|
|
|
|
it("prompt env var contains rendered prompt text", () => {
|
|
const { job, prompt } = buildJobManifest({ ctx, selfPod });
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
const promptEnv = init?.env?.find((e: { name: string }) => e.name === "PROMPT_CONTENT");
|
|
expect(promptEnv?.value).toBe(prompt);
|
|
});
|
|
});
|
|
|
|
describe("claude container", () => {
|
|
it("names container 'claude'", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.name).toBe("claude");
|
|
});
|
|
|
|
it("uses selfPod image by default", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.image).toBe("paperclipai/paperclip:latest");
|
|
});
|
|
|
|
it("uses configured image override", () => {
|
|
ctx.config = { image: "my-image:v2" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.image).toBe("my-image:v2");
|
|
});
|
|
|
|
it("sets imagePullPolicy from config", () => {
|
|
ctx.config = { imagePullPolicy: "Always" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("Always");
|
|
});
|
|
|
|
it("defaults imagePullPolicy to IfNotPresent", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.imagePullPolicy).toBe("IfNotPresent");
|
|
});
|
|
|
|
it("sets workingDir to /paperclip by default", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/paperclip");
|
|
});
|
|
|
|
it("uses workspace cwd when available", () => {
|
|
ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject");
|
|
});
|
|
|
|
it("prefers workspace cwd over configured cwd", () => {
|
|
ctx.config = { cwd: "/custom/path" };
|
|
ctx.context = { paperclipWorkspace: { cwd: "/workspace/myproject" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.containers[0]?.workingDir).toBe("/workspace/myproject");
|
|
});
|
|
});
|
|
|
|
describe("volumes", () => {
|
|
it("creates prompt emptyDir volume", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const promptVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt");
|
|
expect(promptVol?.emptyDir).toEqual({});
|
|
});
|
|
|
|
it("mounts data PVC at /paperclip when pvcClaimName is set", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const dataVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "data");
|
|
expect(dataVol?.persistentVolumeClaim?.claimName).toBe("paperclip-data");
|
|
const dataMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/paperclip");
|
|
expect(dataMount?.name).toBe("data");
|
|
});
|
|
|
|
it("omits data volume when no PVC", () => {
|
|
selfPod.pvcClaimName = null;
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.volumes?.find((v) => v.name === "data")).toBeUndefined();
|
|
});
|
|
|
|
it("mounts secret volumes", () => {
|
|
selfPod.secretVolumes = [{
|
|
volumeName: "my-secret",
|
|
secretName: "app-secret",
|
|
mountPath: "/secrets/app",
|
|
defaultMode: 420,
|
|
}];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "my-secret");
|
|
expect(secretVol?.secret?.secretName).toBe("app-secret");
|
|
const secretMount = job.spec?.template?.spec?.containers[0]?.volumeMounts?.find((vm) => vm.mountPath === "/secrets/app");
|
|
expect(secretMount?.readOnly).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("environment variables", () => {
|
|
it("sets HOME to /paperclip", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const home = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "HOME");
|
|
expect(home?.value).toBe("/paperclip");
|
|
});
|
|
|
|
it("inherits env vars from selfPod", () => {
|
|
selfPod.inheritedEnv = { ANTHROPIC_API_KEY: "sk-abc", AWS_REGION: "us-east-1" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? [];
|
|
expect(envNames).toContain("ANTHROPIC_API_KEY");
|
|
expect(envNames).toContain("AWS_REGION");
|
|
});
|
|
|
|
it("inherits ANTHROPIC_AUTH_TOKEN from selfPod for API auth", () => {
|
|
selfPod.inheritedEnv = { ANTHROPIC_AUTH_TOKEN: "sk-test" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const envNames = job.spec?.template?.spec?.containers[0]?.env?.map((e) => e.name) ?? [];
|
|
expect(envNames).toContain("ANTHROPIC_AUTH_TOKEN");
|
|
});
|
|
|
|
it("user env config overrides inherited env", () => {
|
|
selfPod.inheritedEnv = { AWS_REGION: "us-east-1" };
|
|
ctx.config = { env: { AWS_REGION: "us-west-2" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const awsRegion = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "AWS_REGION");
|
|
expect(awsRegion?.value).toBe("us-west-2");
|
|
});
|
|
|
|
it("sets PAPERCLIP_RUN_ID", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const runId = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_RUN_ID");
|
|
expect(runId?.value).toBe("run-abc12345");
|
|
});
|
|
|
|
it("sets PAPERCLIP_API_KEY from authToken", () => {
|
|
ctx.authToken = "pk_abc123";
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const apiKey = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_KEY");
|
|
expect(apiKey?.value).toBe("pk_abc123");
|
|
});
|
|
|
|
it("inherited PAPERCLIP_API_URL from selfPod takes precedence", () => {
|
|
ctx.authToken = "pk_abc";
|
|
selfPod.inheritedEnv = { PAPERCLIP_API_URL: "http://paperclip:8080" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const apiUrl = job.spec?.template?.spec?.containers[0]?.env?.find((e) => e.name === "PAPERCLIP_API_URL");
|
|
expect(apiUrl?.value).toBe("http://paperclip:8080");
|
|
});
|
|
|
|
it("includes valueFrom env vars from selfPod", () => {
|
|
selfPod.inheritedEnvValueFrom = [
|
|
{ name: "ANTHROPIC_API_KEY", valueFrom: { secretKeyRef: { name: "api-keys", key: "anthropic" } } },
|
|
];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const envList = job.spec?.template?.spec?.containers[0]?.env ?? [];
|
|
const apiKeyEntry = envList.find((e) => e.name === "ANTHROPIC_API_KEY");
|
|
expect(apiKeyEntry?.valueFrom?.secretKeyRef?.name).toBe("api-keys");
|
|
expect(apiKeyEntry?.valueFrom?.secretKeyRef?.key).toBe("anthropic");
|
|
expect(apiKeyEntry?.value).toBeUndefined();
|
|
});
|
|
|
|
it("literal env overrides valueFrom with the same name", () => {
|
|
selfPod.inheritedEnv = { MY_VAR: "literal-value" };
|
|
selfPod.inheritedEnvValueFrom = [
|
|
{ name: "MY_VAR", valueFrom: { secretKeyRef: { name: "sec", key: "k" } } },
|
|
];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const envList = job.spec?.template?.spec?.containers[0]?.env ?? [];
|
|
const myVar = envList.filter((e) => e.name === "MY_VAR");
|
|
expect(myVar).toHaveLength(1);
|
|
expect(myVar[0]?.value).toBe("literal-value");
|
|
expect(myVar[0]?.valueFrom).toBeUndefined();
|
|
});
|
|
|
|
it("includes envFrom sources from selfPod on the container", () => {
|
|
selfPod.inheritedEnvFrom = [
|
|
{ secretRef: { name: "api-secrets" } },
|
|
{ configMapRef: { name: "app-config" } },
|
|
];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const container = job.spec?.template?.spec?.containers[0];
|
|
expect(container?.envFrom).toHaveLength(2);
|
|
expect(container?.envFrom?.[0]?.secretRef?.name).toBe("api-secrets");
|
|
expect(container?.envFrom?.[1]?.configMapRef?.name).toBe("app-config");
|
|
});
|
|
|
|
it("omits envFrom when selfPod has none", () => {
|
|
selfPod.inheritedEnvFrom = [];
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const container = job.spec?.template?.spec?.containers[0];
|
|
expect(container?.envFrom).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("resources", () => {
|
|
it("sets default resource requests and limits", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const resources = job.spec?.template?.spec?.containers[0]?.resources;
|
|
expect(resources?.requests).toEqual({ cpu: "1000m", memory: "2Gi" });
|
|
expect(resources?.limits).toEqual({ cpu: "4000m", memory: "8Gi" });
|
|
});
|
|
|
|
it("uses configured resource overrides", () => {
|
|
ctx.config = {
|
|
"resources.requests.cpu": "500m",
|
|
"resources.requests.memory": "1Gi",
|
|
"resources.limits.cpu": "2000m",
|
|
"resources.limits.memory": "4Gi",
|
|
};
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const resources = job.spec?.template?.spec?.containers[0]?.resources;
|
|
expect(resources?.requests).toEqual({ cpu: "500m", memory: "1Gi" });
|
|
expect(resources?.limits).toEqual({ cpu: "2000m", memory: "4Gi" });
|
|
});
|
|
});
|
|
|
|
describe("nodeSelector and tolerations", () => {
|
|
it("applies nodeSelector from config", () => {
|
|
ctx.config = { nodeSelector: { "topology.kubernetes.io/zone": "us-east-1a" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ "topology.kubernetes.io/zone": "us-east-1a" });
|
|
});
|
|
|
|
it("applies tolerations from config", () => {
|
|
ctx.config = { tolerations: [{ key: "disk", operator: "Equal", value: "ssd", effect: "NoSchedule" }] };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.tolerations).toHaveLength(1);
|
|
});
|
|
|
|
it("omits nodeSelector when empty", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toBeUndefined();
|
|
});
|
|
|
|
it("omits tolerations when empty", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.tolerations).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("claude args", () => {
|
|
it("builds --print - - --output-format stream-json --verbose", () => {
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--print");
|
|
expect(claudeArgs).toContain("-");
|
|
expect(claudeArgs).toContain("--output-format");
|
|
expect(claudeArgs).toContain("stream-json");
|
|
expect(claudeArgs).toContain("--verbose");
|
|
});
|
|
|
|
it("adds --model when configured", () => {
|
|
ctx.config = { model: "claude-opus-4-6" };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--model");
|
|
expect(claudeArgs).toContain("claude-opus-4-6");
|
|
});
|
|
|
|
it("adds --effort when configured", () => {
|
|
ctx.config = { effort: "high" };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--effort");
|
|
expect(claudeArgs).toContain("high");
|
|
});
|
|
|
|
it("adds --max-turns when configured", () => {
|
|
ctx.config = { maxTurnsPerRun: 10 };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--max-turns");
|
|
expect(claudeArgs).toContain("10");
|
|
});
|
|
|
|
it("adds --resume when sessionId present", () => {
|
|
ctx.runtime.sessionId = "sess_abc";
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--resume");
|
|
expect(claudeArgs).toContain("sess_abc");
|
|
});
|
|
|
|
it("adds --dangerously-skip-permissions by default", () => {
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--dangerously-skip-permissions");
|
|
});
|
|
|
|
it("adds --append-system-prompt-file (config fallback) when instructionsFilePath set and no session", () => {
|
|
ctx.config = { instructionsFilePath: "/paperclip/instructions.md" };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--append-system-prompt-file");
|
|
expect(claudeArgs).toContain("/paperclip/instructions.md");
|
|
});
|
|
|
|
it("omits --append-system-prompt-file on session resume (avoids token waste)", () => {
|
|
ctx.config = { instructionsFilePath: "/paperclip/instructions.md" };
|
|
ctx.runtime.sessionId = "sess_existing";
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).not.toContain("--append-system-prompt-file");
|
|
});
|
|
|
|
it("adds --add-dir when promptBundle is provided", () => {
|
|
const promptBundle = {
|
|
bundleKey: "abc123",
|
|
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
instructionsFilePath: null,
|
|
};
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
|
expect(claudeArgs).toContain("--add-dir");
|
|
expect(claudeArgs).toContain(promptBundle.addDir);
|
|
});
|
|
|
|
it("uses bundle instructionsFilePath for --append-system-prompt-file when promptBundle provided", () => {
|
|
const promptBundle = {
|
|
bundleKey: "abc123",
|
|
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md",
|
|
};
|
|
ctx.config = { instructionsFilePath: "/raw/path/AGENTS.md" };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
|
expect(claudeArgs).toContain("--append-system-prompt-file");
|
|
const idx = claudeArgs.indexOf("--append-system-prompt-file");
|
|
expect(claudeArgs[idx + 1]).toBe(promptBundle.instructionsFilePath);
|
|
expect(claudeArgs).not.toContain("/raw/path/AGENTS.md");
|
|
});
|
|
|
|
it("omits --append-system-prompt-file from bundle on session resume", () => {
|
|
const promptBundle = {
|
|
bundleKey: "abc123",
|
|
rootDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
addDir: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123",
|
|
instructionsFilePath: "/paperclip/instances/default/companies/co1/claude-prompt-cache/abc123/agent-instructions.md",
|
|
};
|
|
ctx.runtime.sessionId = "sess_existing";
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod, promptBundle });
|
|
expect(claudeArgs).not.toContain("--append-system-prompt-file");
|
|
// --add-dir must still be present even on resume
|
|
expect(claudeArgs).toContain("--add-dir");
|
|
});
|
|
|
|
it("omits --add-dir when no promptBundle", () => {
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).not.toContain("--add-dir");
|
|
});
|
|
|
|
it("appends extraArgs when configured", () => {
|
|
ctx.config = { extraArgs: ["--no-input", "--verbose"] };
|
|
const { claudeArgs } = buildJobManifest({ ctx, selfPod });
|
|
expect(claudeArgs).toContain("--no-input");
|
|
expect(claudeArgs).toContain("--verbose");
|
|
});
|
|
});
|
|
|
|
describe("prompt rendering", () => {
|
|
it("includes agent name in default prompt template", () => {
|
|
const { prompt } = buildJobManifest({ ctx, selfPod });
|
|
expect(prompt).toContain("Test Agent");
|
|
});
|
|
|
|
it("uses custom promptTemplate when set", () => {
|
|
ctx.config = { promptTemplate: "You are a helpful assistant." };
|
|
const { prompt } = buildJobManifest({ ctx, selfPod });
|
|
expect(prompt).toBe("You are a helpful assistant.");
|
|
});
|
|
|
|
it("includes workspace context in prompt when available", () => {
|
|
ctx.context = {
|
|
paperclipWorkspace: {
|
|
cwd: "/project",
|
|
strategy: "read-only",
|
|
workspaceId: "ws1",
|
|
repoUrl: "https://github.com/org/repo",
|
|
branchName: "main",
|
|
},
|
|
};
|
|
const { prompt } = buildJobManifest({ ctx, selfPod });
|
|
expect(prompt).toContain("Test Agent");
|
|
});
|
|
|
|
it("returns promptMetrics with char counts", () => {
|
|
const { promptMetrics } = buildJobManifest({ ctx, selfPod });
|
|
expect(promptMetrics.promptChars).toBeGreaterThan(0);
|
|
expect(typeof promptMetrics.promptChars).toBe("number");
|
|
});
|
|
});
|
|
|
|
describe("serviceAccountName", () => {
|
|
it("sets custom serviceAccountName when configured", () => {
|
|
ctx.config = { serviceAccountName: "paperclip-agent" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.serviceAccountName).toBe("paperclip-agent");
|
|
});
|
|
|
|
it("omits serviceAccountName when not configured", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.serviceAccountName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("namespace", () => {
|
|
it("uses selfPod namespace by default", () => {
|
|
const { namespace } = buildJobManifest({ ctx, selfPod });
|
|
expect(namespace).toBe("paperclip");
|
|
});
|
|
|
|
it("uses configured namespace override", () => {
|
|
ctx.config = { namespace: "agents" };
|
|
const { namespace, job } = buildJobManifest({ ctx, selfPod });
|
|
expect(namespace).toBe("agents");
|
|
expect(job.metadata?.namespace).toBe("agents");
|
|
});
|
|
});
|
|
|
|
describe("return value", () => {
|
|
it("returns job, jobName, namespace, prompt, claudeArgs, promptMetrics, promptSecret", () => {
|
|
const result = buildJobManifest({ ctx, selfPod });
|
|
expect(result.job).toBeDefined();
|
|
expect(result.jobName).toBeDefined();
|
|
expect(result.namespace).toBeDefined();
|
|
expect(result.prompt).toBeDefined();
|
|
expect(result.claudeArgs).toBeDefined();
|
|
expect(result.promptMetrics).toBeDefined();
|
|
expect(result.promptSecret).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("nodeSelector key=value parsing", () => {
|
|
it("parses key=value multiline text", () => {
|
|
ctx.config = { nodeSelector: "disktype=ssd\ntopology.kubernetes.io/zone=us-east-1a" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toEqual({
|
|
disktype: "ssd",
|
|
"topology.kubernetes.io/zone": "us-east-1a",
|
|
});
|
|
});
|
|
|
|
it("still accepts JSON objects", () => {
|
|
ctx.config = { nodeSelector: { disktype: "ssd" } };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
|
});
|
|
|
|
it("parses JSON string format", () => {
|
|
ctx.config = { nodeSelector: '{"disktype":"ssd"}' };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
|
});
|
|
|
|
it("skips comment lines and blank lines", () => {
|
|
ctx.config = { nodeSelector: "# comment\n\ndisktype=ssd\n" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.spec?.template?.spec?.nodeSelector).toEqual({ disktype: "ssd" });
|
|
});
|
|
});
|
|
|
|
describe("labels key=value parsing", () => {
|
|
it("parses key=value multiline text for extra labels", () => {
|
|
ctx.config = { labels: "env=prod\nteam=platform" };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
expect(job.metadata?.labels?.env).toBe("prod");
|
|
expect(job.metadata?.labels?.team).toBe("platform");
|
|
});
|
|
});
|
|
|
|
describe("large prompt Secret fallback", () => {
|
|
it("returns null promptSecret for small prompts", () => {
|
|
const { promptSecret } = buildJobManifest({ ctx, selfPod });
|
|
expect(promptSecret).toBeNull();
|
|
});
|
|
|
|
it("returns promptSecret for prompts >256 KiB", () => {
|
|
// Build a prompt >256 KiB via a custom template
|
|
const largePrompt = "x".repeat(300 * 1024);
|
|
ctx.config = { promptTemplate: largePrompt };
|
|
const { promptSecret, job } = buildJobManifest({ ctx, selfPod });
|
|
expect(promptSecret).not.toBeNull();
|
|
expect(promptSecret!.data["prompt.txt"]).toBe(largePrompt);
|
|
// Init container should copy from secret volume, not use PROMPT_CONTENT env
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
expect(init?.command).toContainEqual(expect.stringContaining("cp"));
|
|
expect(init?.env).toBeUndefined();
|
|
// Should have prompt-secret volume
|
|
const secretVol = job.spec?.template?.spec?.volumes?.find((v) => v.name === "prompt-secret");
|
|
expect(secretVol?.secret?.secretName).toBe(promptSecret!.name);
|
|
});
|
|
|
|
it("uses env var init container for small prompts", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const init = job.spec?.template?.spec?.initContainers?.[0];
|
|
expect(init?.env?.[0]?.name).toBe("PROMPT_CONTENT");
|
|
});
|
|
});
|
|
|
|
describe("rtk output filtering", () => {
|
|
it("does not modify main command when enableRtk is false (default)", () => {
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
|
// Command should be the plain `cat ... | claude ...` form with no rtk setup
|
|
expect(cmd?.[2]).toMatch(/^cat \/tmp\/prompt\/prompt\.txt \| claude /);
|
|
expect(cmd?.[2]).not.toContain("rtk-filter");
|
|
});
|
|
|
|
it("prepends RTK setup commands when enableRtk is true", () => {
|
|
ctx.config = { enableRtk: true };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const cmd = job.spec?.template?.spec?.containers[0]?.command;
|
|
expect(cmd?.[2]).toContain(".rtk-filter.js");
|
|
expect(cmd?.[2]).toContain("cat /tmp/prompt/prompt.txt | claude");
|
|
});
|
|
|
|
it("RTK setup runs before claude invocation", () => {
|
|
ctx.config = { enableRtk: true };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
|
const rtkIdx = cmd.indexOf(".rtk-filter.js");
|
|
const claudeIdx = cmd.indexOf("cat /tmp/prompt/prompt.txt | claude");
|
|
expect(rtkIdx).toBeGreaterThanOrEqual(0);
|
|
expect(claudeIdx).toBeGreaterThan(rtkIdx);
|
|
});
|
|
|
|
it("RTK setup uses node (no external binaries)", () => {
|
|
ctx.config = { enableRtk: true };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
|
// Should only use `node` — no curl, wget, apt, pip, etc.
|
|
expect(cmd).not.toMatch(/\b(curl|wget|apt|yum|pip|gem|cargo|go\s+get)\b/);
|
|
expect(cmd).toContain("node ");
|
|
});
|
|
|
|
it("uses default 50000 byte threshold when rtkMaxOutputBytes not set", () => {
|
|
ctx.config = { enableRtk: true };
|
|
const setup = buildRtkSetupCommands(50000);
|
|
// The filter script base64 should decode to contain the MAX constant
|
|
const b64Match = setup.match(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/);
|
|
expect(b64Match).not.toBeNull();
|
|
const decoded = Buffer.from(b64Match![1], "base64").toString("utf-8");
|
|
expect(decoded).toContain("50000");
|
|
});
|
|
|
|
it("respects custom rtkMaxOutputBytes", () => {
|
|
ctx.config = { enableRtk: true, rtkMaxOutputBytes: 100000 };
|
|
const { job } = buildJobManifest({ ctx, selfPod });
|
|
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
|
|
// The custom threshold should appear in the base64-encoded filter script
|
|
const b64Matches = [...cmd.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
|
const decoded = b64Matches.map((m) => Buffer.from(m[1], "base64").toString("utf-8")).join("\n");
|
|
expect(decoded).toContain("100000");
|
|
});
|
|
|
|
it("RTK setup installs a PostToolUse hook in claude settings", () => {
|
|
const setup = buildRtkSetupCommands(50000);
|
|
// The settings script (second base64 block) should reference PostToolUse
|
|
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
|
expect(b64Matches.length).toBeGreaterThanOrEqual(2);
|
|
const settingsScript = Buffer.from(b64Matches[1]![1], "base64").toString("utf-8");
|
|
expect(settingsScript).toContain("PostToolUse");
|
|
expect(settingsScript).toContain("settings.json");
|
|
});
|
|
|
|
it("filter script handles string content truncation", () => {
|
|
// Decode the filter script and verify it truncates string content
|
|
const setup = buildRtkSetupCommands(1000);
|
|
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
|
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
|
expect(filterScript).toContain("MAX=1000");
|
|
expect(filterScript).toContain("truncated by paperclip-rtk");
|
|
expect(filterScript).toContain("tool_response");
|
|
expect(filterScript).toContain("tool_result");
|
|
});
|
|
|
|
it("filter script truncates without corrupting multi-byte UTF-8", () => {
|
|
// "中" is U+4E2D, 3 bytes in UTF-8: E4 B8 AD
|
|
// With MAX=5, two "中" (6 bytes) should truncate to one (3 bytes), not
|
|
// produce a replacement character from slicing mid-codepoint.
|
|
const setup = buildRtkSetupCommands(5);
|
|
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
|
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
|
|
|
// Extract the trunc function from the filter script and evaluate it
|
|
const fnMatch = filterScript.match(/(function trunc\(s\)\{.*\})(?=const tr=)/);
|
|
expect(fnMatch).toBeTruthy();
|
|
// eslint-disable-next-line no-eval
|
|
const trunc = eval(`(()=>{const MAX=5;${fnMatch![1]};return trunc;})()`);
|
|
|
|
const result = trunc("中中");
|
|
expect(result).not.toContain("�");
|
|
expect(result).toContain("中");
|
|
expect(result).toContain("truncated by paperclip-rtk");
|
|
// Should report bytes from the actual truncation point, not MAX
|
|
expect(result).toContain("3 bytes truncated");
|
|
});
|
|
|
|
it("filter script handles array content (block format)", () => {
|
|
const setup = buildRtkSetupCommands(50000);
|
|
const b64Matches = [...setup.matchAll(/Buffer\.from\('([A-Za-z0-9+/=]+)','base64'\)/g)];
|
|
const filterScript = Buffer.from(b64Matches[0]![1], "base64").toString("utf-8");
|
|
// Should handle array content blocks (text field on each block)
|
|
expect(filterScript).toContain("Array.isArray");
|
|
expect(filterScript).toContain("b.text");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sanitizeLabelValue", () => {
|
|
it("passes through already-valid UUIDs and slugs", () => {
|
|
expect(sanitizeLabelValue("abc-123-def")).toBe("abc-123-def");
|
|
expect(sanitizeLabelValue("0d8b4472-c42c-4052-aab1-e32897909afa")).toBe("0d8b4472-c42c-4052-aab1-e32897909afa");
|
|
});
|
|
|
|
it("strips characters outside [a-zA-Z0-9._-]", () => {
|
|
expect(sanitizeLabelValue("task:xyz/123")).toBe("taskxyz123");
|
|
expect(sanitizeLabelValue("abc 123")).toBe("abc123");
|
|
});
|
|
|
|
it("trims leading/trailing non-alphanumeric characters", () => {
|
|
expect(sanitizeLabelValue("--abc--")).toBe("abc");
|
|
expect(sanitizeLabelValue("...123...")).toBe("123");
|
|
});
|
|
|
|
it("truncates to the configured maxLen", () => {
|
|
const long = "a".repeat(200);
|
|
const out = sanitizeLabelValue(long, 63);
|
|
expect(out?.length).toBe(63);
|
|
});
|
|
|
|
it("returns null when no alphanumeric characters remain", () => {
|
|
expect(sanitizeLabelValue("---")).toBeNull();
|
|
expect(sanitizeLabelValue("")).toBeNull();
|
|
expect(sanitizeLabelValue(" ")).toBeNull();
|
|
});
|
|
});
|