fix(server): make tailPodLogFile exit on job completion + port c8429cf

- Run tailPodLogFile and waitForJobCompletion in parallel via Promise.allSettled;
  completion sets stopSignal.stopped so the tail loop drains and exits. Without
  this, tailPodLogFile loops forever — the only natural exit was fh.stat()
  throwing on file removal, which never happened during normal job completion.
- Restructure tail loop to read-then-sleep, with a final drain after stopSignal
  is set to capture bytes written between the last poll and terminal state.
- Port the c8429cf fix from paperclip-adapter-claude-k8s:
  * buildPodLogPath now writes to /paperclip/instances/default/data/run-logs/...
    to match the server PVC layout (the /data/ segment was missing).
  * Drop the mkdir -p ... && from both init container command variants — the
    PVC isn't mounted in the init container, so the mkdir was failing with
    exit code 1 and the && short-circuit prevented the prompt copy.
- Test infrastructure:
  * Hoisted fs/promises mock now uses importOriginal so readFile (used for
    skill bundle loading) hits the real implementation.
  * setMockJsonl() lets individual tests inject specific JSONL into the tail's
    read buffer (previously dead constants in the test file).
  * fh.read mock now writes into the caller's buffer instead of returning a
    separate one.
- Add src/server/test.test.ts covering testEnvironment (was 0% → 98.5% stmts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 14:57:40 -04:00
parent bc340bfcc9
commit e3af8aa83b
6 changed files with 296 additions and 66 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.39",
"version": "0.1.40",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-opencode-k8s",
"version": "0.1.39",
"version": "0.1.40",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
+47 -27
View File
@@ -4,33 +4,47 @@ import { execute, ensureAgentDbPvc, tailPodLogFile } from "./execute.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getPvc, createPvc } from "./k8s-client.js";
import { buildJobManifest, buildPodLogPath } from "./job-manifest.js";
// Mock node:fs/promises to prevent tailPodLogFile (used by execute()) from
// hanging on unmocked fs.stat calls in test environment.
// vi.hoisted creates shared module-level state; beforeEach resets it so every
// test gets a clean first-read-success.
const { readMock, resetFsMocks } = vi.hoisted(() => {
// Mock node:fs/promises so tailPodLogFile (used by execute()) reads a
// configurable JSONL payload and returns. Individual tests override the
// payload via setMockJsonl(...) before calling execute().
const { readMock, statMock, fhStatMock, resetFsMocks, setMockJsonl } = vi.hoisted(() => {
const HAPPY = [
JSON.stringify({ type: "text", part: { text: "Task complete" }, sessionID: "ses_happy" }),
JSON.stringify({ type: "step_finish", part: { tokens: { input: 100, output: 50, cache: { read: 20 } }, cost: 0.002 } }),
].join("\n");
let payload = HAPPY;
let buffer = Buffer.from(payload);
let readOffset = 0;
const apply = (next: string) => { payload = next; buffer = Buffer.from(payload); readOffset = 0; };
return {
readMock: vi.fn().mockImplementation(async () => {
if (readOffset === 0) {
readOffset = 17;
return { bytesRead: 17, buffer: Buffer.from('{"type":"text"}\n') };
}
return { bytesRead: 0, buffer: Buffer.alloc(0) };
readMock: vi.fn().mockImplementation(async (buf: Buffer, off: number, len: number, _pos: number) => {
if (readOffset >= buffer.byteLength) return { bytesRead: 0, buffer: buf };
const remaining = buffer.byteLength - readOffset;
const toRead = Math.min(len, remaining);
buffer.copy(buf, off, readOffset, readOffset + toRead);
readOffset += toRead;
return { bytesRead: toRead, buffer: buf };
}),
resetFsMocks: () => { readOffset = 0; },
statMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
fhStatMock: vi.fn().mockImplementation(async () => ({ size: buffer.byteLength })),
resetFsMocks: () => { apply(HAPPY); },
setMockJsonl: (jsonl: string) => { apply(jsonl); },
};
});
vi.mock("node:fs/promises", () => ({
stat: vi.fn().mockResolvedValue({ size: 17 }),
open: vi.fn().mockResolvedValue({
stat: vi.fn().mockResolvedValue({ size: 17 }),
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
stat: statMock,
open: vi.fn().mockResolvedValue({
stat: fhStatMock,
read: readMock,
close: vi.fn().mockResolvedValue(undefined),
}),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
@@ -43,7 +57,7 @@ vi.mock("./k8s-client.js", () => ({
vi.mock("./job-manifest.js", () => ({
buildJobManifest: vi.fn(),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
@@ -169,7 +183,7 @@ beforeEach(() => {
prompt: "Test prompt",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const batchApi = makeBatchApi();
@@ -590,6 +604,7 @@ describe("execute — happy path", () => {
describe("execute — session unavailable (reattach classification)", () => {
it("returns clearSession=true and session_unavailable code for unknown session error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Unknown session ses_xxx" } }));
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -601,6 +616,7 @@ describe("execute — session unavailable (reattach classification)", () => {
});
it("returns clearSession=true for 'session not found' error", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "Session ses_xxx not found" } }));
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -672,6 +688,7 @@ describe("execute — exit code handling", () => {
});
it("synthesizes exitCode=1 when error message exists but pod reported exitCode=0", async () => {
setMockJsonl(JSON.stringify({ type: "error", error: { message: "something went wrong" } }));
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -735,6 +752,7 @@ describe("execute — llm_api_error signal", () => {
it("returns llm_api_error when session exists but LLM produced no output tokens", async () => {
// JSONL has a sessionID but no step_finish tokens and no text messages
const emptyOutputJsonl = JSON.stringify({ sessionID: "ses_empty", type: "step_finish", part: { tokens: { input: 100, output: 0, cache: {} }, cost: 0 } });
setMockJsonl(emptyOutputJsonl);
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -757,6 +775,7 @@ describe("execute — llm_api_error signal", () => {
const errorJsonl = [
JSON.stringify({ sessionID: "ses_err", type: "error", error: { message: "API quota exceeded" } }),
].join("\n");
setMockJsonl(errorJsonl);
const coreApi = makeCoreApi(1);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -939,7 +958,7 @@ describe("execute — large-prompt Secret path", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
}
@@ -1271,7 +1290,7 @@ describe("execute — large-prompt Secret create failure", () => {
prompt: LARGE_PROMPT,
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
} as unknown as ReturnType<typeof buildJobManifest>);
const coreApi = makeCoreApi();
@@ -1305,6 +1324,7 @@ describe("execute — step limit detection", () => {
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");
setMockJsonl(STEP_LIMIT_JSONL);
const coreApi = makeCoreApi(0);
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
@@ -1507,10 +1527,10 @@ describe("execute — SIGTERM handler body (FAR-86 coverage)", () => {
prompt: "p",
opencodeArgs: [],
promptMetrics: null,
podLogPath: `/paperclip/instances/default/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
podLogPath: `/paperclip/instances/default/data/run-logs/co-1/agent-id-test/run-test-123.pod.ndjson`,
}),
buildPodLogPath: vi.fn((companyId: string, agentId: string, runId: string) =>
`/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
`/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`
),
LARGE_PROMPT_THRESHOLD_BYTES: 256 * 1024,
}));
+46 -32
View File
@@ -269,40 +269,45 @@ export async function tailPodLogFile(
let idleCount = 0;
const accumulator: string[] = [];
const drain = async (): Promise<boolean> => {
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
return false;
}
if (size <= offset) return false;
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
return bytesRead > 0;
};
try {
while (!stopSignal.stopped) {
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
await new Promise((r) => setTimeout(r, pollMs));
if (stopSignal.stopped) break;
let size: number;
try {
const stat = await fh.stat();
size = stat.size;
} catch {
break;
}
if (size > offset) {
const buf = Buffer.alloc(size - offset);
const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
offset += bytesRead;
const grew = await drain();
if (grew) {
idleCount = 0;
const chunk = buf.slice(0, bytesRead).toString("utf-8");
const lineParts = (pending + chunk).split("\n");
pending = lineParts.pop() ?? "";
for (const line of lineParts) {
await onLog("stdout", line + "\n");
accumulator.push(line + "\n");
}
} else {
idleCount++;
}
if (stopSignal.stopped) break;
const pollMs = idleCount >= IDLE_THRESHOLD ? POLL_IDLE_MS : POLL_ACTIVE_MS;
await new Promise((r) => setTimeout(r, pollMs));
}
// Final drain on stop
// Final drain after stopSignal — pick up any bytes written between the
// last read and the job reaching terminal state.
while (await drain()) { /* read until no more growth */ }
if (pending) {
await onLog("stdout", pending + "\n");
accumulator.push(pending + "\n");
@@ -462,13 +467,22 @@ async function streamAndAwaitJob(
return onLog(stream, chunk);
};
const tailResult = await tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal });
stdout = tailResult;
// Wait for job completion (may already be done by the time we read the file)
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath);
// Run the file tail and the job-completion poll in parallel so that the
// tail loop has a way to stop: when waitForJobCompletion resolves it sets
// stopSignal.stopped, which lets tailPodLogFile drain and return.
const completionPromise = waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath)
.then((r) => { stopSignal.stopped = true; return r; });
const completionGraced = completionWithGrace(completionPromise, LOG_EXIT_COMPLETION_GRACE_MS);
const completion = await completionGraced;
const [tailSettled, completionSettled] = await Promise.allSettled([
tailPodLogFile(podLogPath, { onLog: wrappedOnLog, stopSignal }),
completionGraced,
]);
stdout = tailSettled.status === "fulfilled" ? tailSettled.value : "";
if (completionSettled.status === "rejected") {
stopSignal.stopped = true;
throw completionSettled.reason;
}
const completion = completionSettled.value;
if (keepaliveTimer) {
clearInterval(keepaliveTimer);
+3 -2
View File
@@ -359,8 +359,9 @@ describe("init container is unchanged by agentDbClaimName", () => {
it("does not add 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;
// mkdir is added for log directory but OPENCODE_DB_PATH env var is NOT added
expect(initCmd?.[2]).toContain("mkdir");
// init container only writes the prompt; no mkdir (log dir exists on PVC) and no OPENCODE_DB_PATH env var
expect(initCmd?.[2]).not.toContain("mkdir");
expect(initCmd?.[2]).toContain("/tmp/prompt/prompt.txt");
const initEnv = result.job.spec?.template?.spec?.initContainers?.[0].env ?? [];
expect(initEnv.some((e) => e.name === "OPENCODE_DB_PATH")).toBe(false);
});
+3 -3
View File
@@ -25,7 +25,7 @@ function assertSafePathComponent(field: string, value: string): void {
}
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
}
export interface JobBuildInput {
@@ -461,14 +461,14 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
imagePullPolicy: "IfNotPresent",
...(input.promptSecretName
? {
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `cp /tmp/prompt-secret/prompt /tmp/prompt/prompt.txt`],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
],
}
: {
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${companyId}/${agentId} && printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt`],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
}),
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AdapterEnvironmentTestContext } from "@paperclipai/adapter-utils";
import { testEnvironment } from "./test.js";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
vi.mock("./k8s-client.js", () => ({
getSelfPodInfo: vi.fn(),
getCoreApi: vi.fn(),
getAuthzApi: vi.fn(),
}));
const SELF_POD = {
namespace: "ns-self",
image: "img:1",
imagePullSecrets: [],
pvcClaimName: "paperclip-pvc",
inheritedEnv: {},
inheritedEnvValueFrom: [],
inheritedEnvFrom: [],
dnsConfig: undefined,
secretVolumes: [],
} as unknown as Awaited<ReturnType<typeof getSelfPodInfo>>;
function makeCtx(config: Record<string, unknown> = {}): AdapterEnvironmentTestContext {
return { adapterType: "opencode_k8s", config } as unknown as AdapterEnvironmentTestContext;
}
function makeAuthz(allowedFor: (resource: string, verb: string) => boolean) {
return {
createSelfSubjectAccessReview: vi.fn().mockImplementation(async ({ body }: { body: { spec: { resourceAttributes: { resource: string; verb: string } } } }) => {
const { resource, verb } = body.spec.resourceAttributes;
return { status: { allowed: allowedFor(resource, verb) } };
}),
};
}
function makeCore(overrides: Partial<{ readNamespace: ReturnType<typeof vi.fn>; readNamespacedSecret: ReturnType<typeof vi.fn>; readNamespacedPersistentVolumeClaim: ReturnType<typeof vi.fn> }> = {}) {
return {
readNamespace: overrides.readNamespace ?? vi.fn().mockResolvedValue({ metadata: { name: "ns" } }),
readNamespacedSecret: overrides.readNamespacedSecret ?? vi.fn().mockResolvedValue({ metadata: { name: "paperclip-secrets" } }),
readNamespacedPersistentVolumeClaim: overrides.readNamespacedPersistentVolumeClaim ?? vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteMany"] } }),
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSelfPodInfo).mockResolvedValue(SELF_POD);
vi.mocked(getCoreApi).mockReturnValue(makeCore() as unknown as ReturnType<typeof getCoreApi>);
vi.mocked(getAuthzApi).mockReturnValue(makeAuthz(() => true) as unknown as ReturnType<typeof getAuthzApi>);
});
describe("testEnvironment — happy path", () => {
it("returns pass when API, namespace, RBAC, secret, and RWX PVC all check out", async () => {
const result = await testEnvironment(makeCtx());
expect(result.adapterType).toBe("opencode_k8s");
expect(result.status).toBe("pass");
expect(result.checks.find((c) => c.code === "k8s_api_reachable")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_pvc_rwx")).toBeDefined();
expect(result.checks.find((c) => c.code === "k8s_secret_exists")).toBeDefined();
expect(typeof result.testedAt).toBe("string");
});
it("skips namespace lookup and emits k8s_namespace_exists when target == self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(coreApi.readNamespace).not.toHaveBeenCalled();
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")?.message).toContain("pod namespace");
});
it("calls readNamespace when target namespace differs from self pod namespace", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(coreApi.readNamespace).toHaveBeenCalledWith({ name: "ns-other" });
expect(result.checks.find((c) => c.code === "k8s_namespace_exists")).toBeDefined();
});
});
describe("testEnvironment — early-return paths", () => {
it("returns fail and short-circuits when K8s API is unreachable", async () => {
vi.mocked(getSelfPodInfo).mockRejectedValueOnce(new Error("ECONNREFUSED"));
const result = await testEnvironment(makeCtx());
expect(result.status).toBe("fail");
expect(result.checks.find((c) => c.code === "k8s_api_unreachable")).toBeDefined();
// RBAC, secret, and PVC checks should be skipped when API is unreachable
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(false);
});
});
describe("testEnvironment — namespace warning", () => {
it("emits warn (but proceeds) when readNamespace fails for a different namespace", async () => {
const coreApi = makeCore({
readNamespace: vi.fn().mockRejectedValue(new Error("forbidden")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx({ namespace: "ns-other" }));
expect(result.checks.find((c) => c.code === "k8s_namespace_check_failed")).toBeDefined();
// Should still proceed with downstream checks
expect(result.checks.some((c) => c.code.startsWith("k8s_rbac_"))).toBe(true);
});
});
describe("testEnvironment — RBAC", () => {
it("emits error checks for denied verbs and degrades status to fail", async () => {
vi.mocked(getAuthzApi).mockReturnValue(
makeAuthz((resource, verb) => !(resource === "jobs" && verb === "create")) as unknown as ReturnType<typeof getAuthzApi>,
);
const result = await testEnvironment(makeCtx());
const denied = result.checks.find((c) => c.code === "k8s_rbac_job_create");
expect(denied?.level).toBe("error");
expect(result.status).toBe("fail");
});
it("emits warn when SelfSubjectAccessReview itself throws", async () => {
vi.mocked(getAuthzApi).mockReturnValue({
createSelfSubjectAccessReview: vi.fn().mockRejectedValue(new Error("SSAR not available")),
} as unknown as ReturnType<typeof getAuthzApi>);
const result = await testEnvironment(makeCtx());
const rbacWarns = result.checks.filter((c) => c.code.startsWith("k8s_rbac_") && c.level === "warn");
expect(rbacWarns.length).toBeGreaterThan(0);
});
});
describe("testEnvironment — secrets", () => {
it("emits warn when the secret is not found", async () => {
const coreApi = makeCore({
readNamespacedSecret: vi.fn().mockRejectedValue(new Error("not found")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_secret_missing")).toBeDefined();
expect(result.status).toBe("warn");
});
it("uses configured secretRef when provided", async () => {
const coreApi = makeCore();
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
await testEnvironment(makeCtx({ secretRef: "custom-secret" }));
expect(coreApi.readNamespacedSecret).toHaveBeenCalledWith({ name: "custom-secret", namespace: "ns-self" });
});
});
describe("testEnvironment — PVC", () => {
it("emits warn when no PVC is mounted on /paperclip", async () => {
vi.mocked(getSelfPodInfo).mockResolvedValue({ ...SELF_POD, pvcClaimName: null });
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_not_detected")).toBeDefined();
expect(result.status).toBe("warn");
});
it("emits warn when PVC access mode is not ReadWriteMany", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockResolvedValue({ spec: { accessModes: ["ReadWriteOnce"] } }),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
const pvcCheck = result.checks.find((c) => c.code === "k8s_pvc_not_rwx");
expect(pvcCheck).toBeDefined();
expect(pvcCheck?.message).toContain("ReadWriteOnce");
expect(result.status).toBe("warn");
});
it("emits warn when reading the PVC fails", async () => {
const coreApi = makeCore({
readNamespacedPersistentVolumeClaim: vi.fn().mockRejectedValue(new Error("api error")),
});
vi.mocked(getCoreApi).mockReturnValue(coreApi as unknown as ReturnType<typeof getCoreApi>);
const result = await testEnvironment(makeCtx());
expect(result.checks.find((c) => c.code === "k8s_pvc_check_failed")).toBeDefined();
});
});