Files
paperclip-adapter-opencode-k8s/src/server/k8s-client.test.ts
T
Chris Farhood 798b80f2f2 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>
2026-04-25 22:27:04 +00:00

297 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
/**
* Regression coverage for FAR-85: the @kubernetes/client-node v1.x ApiException
* exposes the HTTP status as `code`, not `statusCode`. The previous `isNotFound`
* predicate only checked `statusCode`/`response.statusCode`, so real 404s were
* never recognized — `getPvc` re-threw the 404 instead of returning null, and
* `ensureAgentDbPvc`'s existence check died before the create path ran.
*
* These tests mock the underlying k8s SDK and feed `getPvc`/`deletePvc` errors
* shaped exactly like the real ApiException so the predicate is exercised
* end-to-end, not in isolation.
*/
vi.mock("@kubernetes/client-node", () => {
// Reproduces the real @kubernetes/client-node v1.x ApiException shape:
// HTTP status under `code`, plus `body` and `headers`. Defined inside the
// factory because vi.mock() is hoisted above any module-level declarations.
class ApiException<T> extends Error {
code: number;
body: T;
headers: Record<string, string>;
constructor(code: number, message: string, body: T, headers: Record<string, string> = {}) {
super(`HTTP-Code: ${code}\nMessage: ${message}\nBody: ${JSON.stringify(body)}`);
this.code = code;
this.body = body;
this.headers = headers;
}
}
class KubeConfig {
loadFromCluster = mockLoadFromCluster;
loadFromFile = mockLoadFromFile;
makeApiClient() {
return {
readNamespacedPersistentVolumeClaim: mockReadNamespacedPVC,
deleteNamespacedPersistentVolumeClaim: mockDeleteNamespacedPVC,
createNamespacedPersistentVolumeClaim: mockCreateNamespacedPVC,
readNamespacedPod: mockReadNamespacedPod,
};
}
}
return {
KubeConfig,
CoreV1Api: class {},
BatchV1Api: class {},
AuthorizationV1Api: class {},
Log: class {},
ApiException,
};
});
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, 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;
beforeEach(() => {
resetCache();
vi.resetAllMocks();
});
describe("getPvc — 404 detection (FAR-85 regression)", () => {
const NAMESPACE = "paperclip";
const NAME = "opencode-db-test";
it("returns the PVC on success", async () => {
const pvc = { metadata: { name: NAME, namespace: NAMESPACE } };
mockReadNamespacedPVC.mockResolvedValue(pvc);
const result = await getPvc(NAMESPACE, NAME);
expect(result).toEqual(pvc);
expect(mockReadNamespacedPVC).toHaveBeenCalledWith({ name: NAME, namespace: NAMESPACE });
});
it("returns null when the SDK throws ApiException with code=404 (v1.x shape)", async () => {
mockReadNamespacedPVC.mockRejectedValue(
new ApiException(404, "Unknown API Status Code!", {
kind: "Status",
status: "Failure",
message: `persistentvolumeclaims "${NAME}" not found`,
reason: "NotFound",
code: 404,
}),
);
const result = await getPvc(NAMESPACE, NAME);
expect(result).toBeNull();
});
it("returns null for legacy errors with statusCode=404", async () => {
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { statusCode: 404 }));
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
});
it("returns null for legacy errors with response.statusCode=404", async () => {
mockReadNamespacedPVC.mockRejectedValue(Object.assign(new Error("not found"), { response: { statusCode: 404 } }));
expect(await getPvc(NAMESPACE, NAME)).toBeNull();
});
it("re-throws non-404 ApiException (e.g. 500)", async () => {
const err = new ApiException(500, "Internal Error", { message: "boom" });
mockReadNamespacedPVC.mockRejectedValue(err);
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
});
it("re-throws 403 (Forbidden) — must not be silently masked as missing", async () => {
const err = new ApiException(403, "Forbidden", { message: "rbac denied" });
mockReadNamespacedPVC.mockRejectedValue(err);
await expect(getPvc(NAMESPACE, NAME)).rejects.toBe(err);
});
});
describe("deletePvc — 404 detection", () => {
const NAMESPACE = "paperclip";
const NAME = "opencode-db-test";
it("swallows ApiException with code=404 (already gone)", async () => {
mockDeleteNamespacedPVC.mockRejectedValue(
new ApiException(404, "Unknown API Status Code!", { reason: "NotFound" }),
);
await expect(deletePvc(NAMESPACE, NAME)).resolves.toBeUndefined();
});
it("re-throws non-404 errors", async () => {
const err = new ApiException(409, "Conflict", { reason: "Conflict" });
mockDeleteNamespacedPVC.mockRejectedValue(err);
await expect(deletePvc(NAMESPACE, NAME)).rejects.toBe(err);
});
});
describe("createPvc — passes through to SDK", () => {
it("forwards the spec to createNamespacedPersistentVolumeClaim", async () => {
const spec = {
apiVersion: "v1",
kind: "PersistentVolumeClaim",
metadata: { name: "opencode-db-x", namespace: "paperclip" },
spec: { accessModes: ["ReadWriteOnce"], resources: { requests: { storage: "1Gi" } } },
};
mockCreateNamespacedPVC.mockResolvedValue(spec);
const result = await createPvc("paperclip", spec as never);
expect(result).toEqual(spec);
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();
});
});