|
| 1 | +/** |
| 2 | + * temp-cleanup.test.ts — Eval 临时产物清理器测试 |
| 3 | + * |
| 4 | + * 这些测试的重点不是“rm 能不能工作”,而是验证清理边界: |
| 5 | + * - 只删除白名单 eval 目录 |
| 6 | + * - manifest 优先于 mtime |
| 7 | + * - trace 只删除旧 trace JSON |
| 8 | + * - dry-run 不产生真实删除 |
| 9 | + */ |
| 10 | + |
| 11 | +import { existsSync } from "node:fs"; |
| 12 | +import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; |
| 13 | +import { tmpdir } from "node:os"; |
| 14 | +import { join } from "node:path"; |
| 15 | +import { afterEach, describe, expect, it } from "vitest"; |
| 16 | +import { |
| 17 | + cleanupEvalArtifacts, |
| 18 | + parseEvalCleanupDuration, |
| 19 | + writeEvalArtifactManifest, |
| 20 | +} from "./temp-cleanup.js"; |
| 21 | + |
| 22 | +describe("cleanupEvalArtifacts", () => { |
| 23 | + const roots: string[] = []; |
| 24 | + const baseNow = new Date("2026-06-12T00:00:00.000Z"); |
| 25 | + const sevenDays = parseEvalCleanupDuration("7d"); |
| 26 | + |
| 27 | + afterEach(async () => { |
| 28 | + const pending = roots.splice(0); |
| 29 | + await Promise.all( |
| 30 | + pending.map((root) => rm(root, { recursive: true, force: true })), |
| 31 | + ); |
| 32 | + }); |
| 33 | + |
| 34 | + it("removes only old directories with eval whitelist prefixes", async () => { |
| 35 | + const root = await createRoot(); |
| 36 | + const oldEval = join(root, "eval-old-case"); |
| 37 | + const youngEval = join(root, "eval-young-case"); |
| 38 | + const unrelated = join(root, "project-cache-old"); |
| 39 | + await mkdir(oldEval); |
| 40 | + await mkdir(youngEval); |
| 41 | + await mkdir(unrelated); |
| 42 | + await touchMtime(oldEval, new Date(baseNow.getTime() - sevenDays - 1000)); |
| 43 | + await touchMtime(youngEval, baseNow); |
| 44 | + await touchMtime(unrelated, new Date(baseNow.getTime() - sevenDays - 1000)); |
| 45 | + |
| 46 | + const result = await cleanupEvalArtifacts({ |
| 47 | + rootDir: root, |
| 48 | + olderThanMs: sevenDays, |
| 49 | + now: baseNow, |
| 50 | + }); |
| 51 | + |
| 52 | + expect(result.errors).toHaveLength(0); |
| 53 | + expect(result.deleted.map((entry) => entry.path)).toContain(oldEval); |
| 54 | + expect(existsSync(oldEval)).toBe(false); |
| 55 | + expect(existsSync(youngEval)).toBe(true); |
| 56 | + expect(existsSync(unrelated)).toBe(true); |
| 57 | + }); |
| 58 | + |
| 59 | + it("uses manifest expiresAt before falling back to mtime", async () => { |
| 60 | + const root = await createRoot(); |
| 61 | + const expiredHome = join(root, "learn-claude-eval-home-expired"); |
| 62 | + const activeHome = join(root, "learn-claude-eval-home-active"); |
| 63 | + await mkdir(expiredHome); |
| 64 | + await mkdir(activeHome); |
| 65 | + await writeEvalArtifactManifest(expiredHome, { |
| 66 | + caseId: "expired-case", |
| 67 | + kind: "agentHome", |
| 68 | + now: new Date(baseNow.getTime() - 2 * sevenDays), |
| 69 | + ttlMs: sevenDays, |
| 70 | + }); |
| 71 | + await writeEvalArtifactManifest(activeHome, { |
| 72 | + caseId: "active-case", |
| 73 | + kind: "agentHome", |
| 74 | + now: baseNow, |
| 75 | + ttlMs: sevenDays, |
| 76 | + }); |
| 77 | + |
| 78 | + const result = await cleanupEvalArtifacts({ |
| 79 | + rootDir: root, |
| 80 | + olderThanMs: sevenDays, |
| 81 | + now: baseNow, |
| 82 | + }); |
| 83 | + |
| 84 | + expect(result.errors).toHaveLength(0); |
| 85 | + expect(existsSync(expiredHome)).toBe(false); |
| 86 | + expect(existsSync(activeHome)).toBe(true); |
| 87 | + }); |
| 88 | + |
| 89 | + it("removes old trace JSON files without touching unrelated trace files", async () => { |
| 90 | + const root = await createRoot(); |
| 91 | + const traceDir = join(root, "eval-traces"); |
| 92 | + const oldTrace = join(traceDir, "case-a.trace.json"); |
| 93 | + const youngTrace = join(traceDir, "case-b.trace.json"); |
| 94 | + const note = join(traceDir, "notes.txt"); |
| 95 | + await mkdir(traceDir); |
| 96 | + await writeFile(oldTrace, "{}", "utf-8"); |
| 97 | + await writeFile(youngTrace, "{}", "utf-8"); |
| 98 | + await writeFile(note, "keep", "utf-8"); |
| 99 | + await touchMtime(oldTrace, new Date(baseNow.getTime() - sevenDays - 1000)); |
| 100 | + await touchMtime(youngTrace, baseNow); |
| 101 | + await touchMtime(note, new Date(baseNow.getTime() - sevenDays - 1000)); |
| 102 | + |
| 103 | + const result = await cleanupEvalArtifacts({ |
| 104 | + rootDir: root, |
| 105 | + olderThanMs: sevenDays, |
| 106 | + now: baseNow, |
| 107 | + }); |
| 108 | + |
| 109 | + expect(result.errors).toHaveLength(0); |
| 110 | + expect(result.deleted.map((entry) => entry.path)).toContain(oldTrace); |
| 111 | + expect(existsSync(oldTrace)).toBe(false); |
| 112 | + expect(existsSync(youngTrace)).toBe(true); |
| 113 | + expect(existsSync(note)).toBe(true); |
| 114 | + }); |
| 115 | + |
| 116 | + it("reports deletions in dry-run mode without removing files", async () => { |
| 117 | + const root = await createRoot(); |
| 118 | + const oldEval = join(root, "eval-dry-run-case"); |
| 119 | + await mkdir(oldEval); |
| 120 | + await touchMtime(oldEval, new Date(baseNow.getTime() - sevenDays - 1000)); |
| 121 | + |
| 122 | + const result = await cleanupEvalArtifacts({ |
| 123 | + rootDir: root, |
| 124 | + olderThanMs: sevenDays, |
| 125 | + now: baseNow, |
| 126 | + dryRun: true, |
| 127 | + }); |
| 128 | + |
| 129 | + expect(result.dryRun).toBe(true); |
| 130 | + expect(result.deleted.map((entry) => entry.path)).toContain(oldEval); |
| 131 | + expect(existsSync(oldEval)).toBe(true); |
| 132 | + }); |
| 133 | + |
| 134 | + async function createRoot(): Promise<string> { |
| 135 | + const root = await mkdtemp(join(tmpdir(), "eval-cleanup-test-")); |
| 136 | + roots.push(root); |
| 137 | + return root; |
| 138 | + } |
| 139 | + |
| 140 | + async function touchMtime(path: string, time: Date): Promise<void> { |
| 141 | + await utimes(path, time, time); |
| 142 | + } |
| 143 | +}); |
0 commit comments