Compare commits

...

2 Commits

Author SHA1 Message Date
Chris Farhood 32d6308eae 0.1.37 2026-04-24 13:11:13 +00:00
Chris Farhood b97117e10d test: mock readPaperclipRuntimeSkillEntries to eliminate real fs I/O under fake timers
Previously the test suite relied on real fs.stat completing within the fake
timer advance window (~11200ms).  Under CI with 11 parallel test files the I/O
could drain later than the advances allowed, causing a 1-in-4 timeout on the
"logs pod pending" test.

Fix: mock @paperclipai/adapter-utils/server-utils using vi.hoisted() + Object.assign
so readPaperclipRuntimeSkillEntries resolves immediately as a microtask.  All other
exports are forwarded to the real module via importOriginal.  Each beforeEach that
calls vi.resetAllMocks() or vi.clearAllMocks() now also calls
mockReadSkillEntries.mockResolvedValue([]) to restore the implementation.

Timer advances in affected tests are simplified to reflect the purely fake-timer
sequence (no I/O drain prefix).  All 323 tests pass deterministically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 13:11:04 +00:00
3 changed files with 60 additions and 58 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.35",
"version": "0.1.37",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.35",
"version": "0.1.37",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.36",
"version": "0.1.37",
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
"license": "MIT",
"repository": {
+57 -55
View File
@@ -18,6 +18,11 @@ const mockCoreReadPodLog = vi.fn();
const mockCoreCreateSecret = vi.fn();
const mockCorePatchSecret = vi.fn();
const mockCoreDeleteSecret = vi.fn();
// vi.hoisted ensures a single vi.fn() instance shared between the mock factory
// (which runs at hoist time) and the test body (which calls mockResolvedValue).
// A plain const would be re-assigned at its original position, leaving the
// factory with a stale reference to a different vi.fn() instance.
const mockReadSkillEntries = vi.hoisted(() => vi.fn());
vi.mock("./k8s-client.js", () => ({
getLogApi: () => ({ log: mockLogFn }),
@@ -45,6 +50,15 @@ vi.mock("./prompt-cache.js", () => ({
prepareClaudePromptBundle: mockPrepareBundle,
}));
vi.mock("@paperclipai/adapter-utils/server-utils", async (importOriginal) => {
const original = await importOriginal<typeof import("@paperclipai/adapter-utils/server-utils")>();
// Enumerate all original exports so transitive deps (job-manifest.ts, parse.ts,
// prompt-cache.ts, etc.) keep working. Only readPaperclipRuntimeSkillEntries
// is replaced so tests run without real fs.stat I/O under fake timers.
return Object.assign(Object.create(null), original, {
readPaperclipRuntimeSkillEntries: mockReadSkillEntries,
});
});
const { isK8s404, buildPartialRunError, classifyOrphan, describePodTerminatedError, streamPodLogsOnce, execute } = await import("./execute.js");
@@ -442,6 +456,7 @@ const CLAUDE_HAPPY_OUTPUT = [
describe("execute: concurrency guard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockReadSkillEntries.mockResolvedValue([]);
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
});
@@ -537,6 +552,7 @@ describe("execute: concurrency guard", () => {
describe("execute: job creation", () => {
beforeEach(() => {
vi.resetAllMocks();
mockReadSkillEntries.mockResolvedValue([]);
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
mockBatchListJobs.mockResolvedValue({ items: [] }); // no concurrent jobs
mockPrepareBundle.mockResolvedValue(makeBundle());
@@ -588,6 +604,7 @@ describe("execute: happy path", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
mockReadSkillEntries.mockResolvedValue([]);
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
mockBatchListJobs.mockResolvedValue({ items: [] });
mockPrepareBundle.mockResolvedValue(makeBundle());
@@ -831,29 +848,20 @@ describe("execute: happy path", () => {
makeCtx({ config: { timeoutSec: 4, graceSec: 0 } } as Partial<AdapterExecutionContext>),
);
// Timer sequence (all times relative to when execute() registers fake timers,
// which is after readPaperclipRuntimeSkillEntries real I/O completes):
// t+2000: waitForJobCompletion poll (non-terminal → sleep 2000ms more)
// t+3000: streamPodLogs reconnect (attempt=1 → sleep 3000ms more)
// t+4000: waitForJobCompletion deadline exceeded → timedOut=true → stopped=true
// t+6000: streamPodLogs sleep ends → while(!stopped) → exits → allSettled resolves
//
// readPaperclipRuntimeSkillEntries does real fs.stat I/O under fake timers.
// Each advanceTimersByTimeAsync call gives one real event-loop turn for that
// I/O to complete. Multiple small advances ensure I/O drains before fake timers
// fire, and the total (~12200ms) covers the I/O delay plus the 6000ms sequence.
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(1_100);
await vi.advanceTimersByTimeAsync(1_000);
// readPaperclipRuntimeSkillEntries is mocked (no real I/O). Timer sequence:
// t=2000: waitForJobCompletion poll 2 (non-terminal → sleep 2000ms)
// t=3000: streamPodLogs reconnect sleep fires (attempt=1 → sleep 3000ms more)
// t=4000: waitForJobCompletion deadline exceeded → timedOut=true → stopped=true
// t=6000: reconnect sleep fires → while(!stopped) → exits → allSettled resolves
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(3_000);
await vi.advanceTimersByTimeAsync(3_000);
const result = await executePromise;
expect(result.timedOut).toBe(true);
expect(result.errorCode).toBe("timeout");
}, 15_000);
});
it("waitForJobCompletion respects deadline and returns timedOut via poll loop", async () => {
// timeoutSec=1, graceSec=0 → completionTimeoutMs=1000ms. The poll delay (2s)
@@ -863,21 +871,16 @@ describe("execute: happy path", () => {
const executePromise = execute(
makeCtx({ config: { timeoutSec: 1, graceSec: 0 } } as Partial<AdapterExecutionContext>),
);
// Multiple advances provide event-loop turns for readPaperclipRuntimeSkillEntries
// I/O to drain before fake timers fire. Total ~8400ms covers the I/O delay
// (~4200ms observed in isolation) plus the 3000ms timer sequence needed:
// t+2000: poll 2 fires, while(Date.now() < deadline) = false → timedOut=true → stopped=true
// t+3000: reconnect sleep fires → while(!stopped) → exits → allSettled resolves
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(1_100);
// readPaperclipRuntimeSkillEntries is mocked (no real I/O). Timer sequence:
// t=2000: poll sleep fires → Date.now()=2000 > deadline=1000 → timedOut=true → stopped=true
// t=3000: reconnect sleep fires → while(!stopped) → exits → allSettled resolves
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(2_100);
const result = await executePromise;
expect(result.timedOut).toBe(true);
expect(result.errorCode).toBe("timeout");
}, 15_000);
});
it("waits for pod creation (no-pod state) then succeeds when pod appears", async () => {
// Override mockCoreListPods: first call returns empty (no pod yet),
@@ -902,19 +905,16 @@ describe("execute: happy path", () => {
const executePromise = execute(makeCtx());
// Multiple advances provide event-loop turns for readPaperclipRuntimeSkillEntries
// I/O to drain. Total ~9400ms covers I/O delay (~4200ms) plus the 5000ms
// sequence: waitForPod sleep (2000ms) → pod found → allSettled starts
// waitForJobCompletion Complete (immediate, stopped=true) → streamPodLogs
// reconnect sleep (3000ms) → while(!stopped) → exits.
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(1_100);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(3_100);
// readPaperclipRuntimeSkillEntries is mocked (no real I/O). Timer sequence:
// t=2000: waitForPod sleep fires → Running pod found → streaming starts
// waitForJobCompletion Complete immediately → stopped=true (microtask)
// t=5000: reconnect sleep fires → while(!stopped) → exits → allSettled resolves
await vi.advanceTimersByTimeAsync(2_000);
await vi.advanceTimersByTimeAsync(3_000);
const result = await executePromise;
expect(result.exitCode).toBe(0);
}, 15_000);
});
it("logs warning and continues when instructionsFilePath file does not exist", async () => {
// The catch block in execute() logs a warning and proceeds with null instructions
@@ -967,16 +967,19 @@ describe("execute: happy path", () => {
});
const executePromise = execute(makeCtx());
// First poll: Pending → logs phase, schedules next poll at t=2000
// Second poll: Running → waitForPod returns, then log streaming begins
// Third advance: fires streamPodLogs 3s reconnect timer
await vi.advanceTimersByTimeAsync(2_100); // t=0→2100: first waitForPod poll (Pending)
await vi.advanceTimersByTimeAsync(2_100); // t=2100→4200: second waitForPod poll (Running)
await vi.advanceTimersByTimeAsync(3_100); // t=4200→7300: streamPodLogs reconnect
// Timer sequence:
// t+2000: waitForPod poll 1 (Pending → logs phase)
// t+4000: waitForPod poll 2 (Running → pod found, streaming starts)
// t+7000: streamPodLogs 3s reconnect sleep fires → while(!stopped) → exits
// readPaperclipRuntimeSkillEntries is mocked (no real I/O), so fake timers
// apply from the moment execute() is called.
await vi.advanceTimersByTimeAsync(2_000); // t+2000: poll 1 fires
await vi.advanceTimersByTimeAsync(2_000); // t+4000: poll 2 fires → pod found
await vi.advanceTimersByTimeAsync(3_000); // t+7000: reconnect sleep fires → done
const result = await executePromise;
expect(result.exitCode).toBe(0);
}, 15_000);
});
it("returns running pod via allInitsDone && mainRunning even when phase=Pending", async () => {
// Phase stays Pending, but init containers are done and main is running.
@@ -1013,6 +1016,7 @@ describe("execute: happy path", () => {
describe("execute: waitForPod edge cases", () => {
beforeEach(() => {
vi.resetAllMocks();
mockReadSkillEntries.mockResolvedValue([]);
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
mockBatchListJobs.mockResolvedValue({ items: [] });
mockPrepareBundle.mockResolvedValue(makeBundle());
@@ -1111,6 +1115,7 @@ describe("execute: log-stream-exit grace period (FAR-23)", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.useFakeTimers();
mockReadSkillEntries.mockResolvedValue([]);
mockGetSelfPodInfo.mockResolvedValue(makeSelfPodResult());
mockBatchListJobs.mockResolvedValue({ items: [] });
mockPrepareBundle.mockResolvedValue(makeBundle());
@@ -1148,17 +1153,14 @@ describe("execute: log-stream-exit grace period (FAR-23)", () => {
// No timeoutSec → completionTimeoutMs=0 → polls indefinitely without grace.
const executePromise = execute(makeCtx());
// Advance past:
// ~4200ms of readPaperclipRuntimeSkillEntries real I/O (multiple small advances)
// 3100ms: streamPodLogs reconnect sleep (attempt=1, stopSignal still false at entry)
// + 30100ms: grace period (LOG_EXIT_COMPLETION_GRACE_MS = 30s)
// + 3100ms: streamPodLogs bail timer (stopSignal was set by grace → bail fires)
await vi.advanceTimersByTimeAsync(2_100);
await vi.advanceTimersByTimeAsync(1_100);
await vi.advanceTimersByTimeAsync(1_000);
await vi.advanceTimersByTimeAsync(3_100); // reconnect sleep
await vi.advanceTimersByTimeAsync(30_100); // grace fires
await vi.advanceTimersByTimeAsync(3_500); // bail timer + margin
// readPaperclipRuntimeSkillEntries is mocked (no real I/O). waitForPod
// resolves immediately (Running pod on first poll). Timer sequence:
// t=3000: first reconnect sleep → loop continues (stopSignal still false)
// t=30000: gracePoller fires → stopSignal.stopped = true
// t≤33000: current sleep fires → while(!stopped) → exit → trackedLogStream resolves
await vi.advanceTimersByTimeAsync(3_100); // first reconnect sleep
await vi.advanceTimersByTimeAsync(30_100); // grace fires at t=30000
await vi.advanceTimersByTimeAsync(3_500); // remaining sleep + margin
const result = await executePromise;
// Grace fires → jobGone=true → execute proceeds with one-shot logs → success