/** * Retention is by AGE, and the test that matters is the one asserting the * FIRST failing run survives a long streak. A count-based rule passes every * other test here and fails that one — which is exactly the bug this * policy exists to avoid, since the first artifact set carries the original * cause and later ones repeat it. * * Uses a real temp directory rather than an injected filesystem: mtime * ordering and directory sizing are the behaviour under test, and a fake * would be asserting my own model of them. */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { ARTIFACT_RETENTION_MS, pruneModuleArtifacts } from './artifact-retention'; const HOUR = 60 * 60 * 1000; const NOW = Date.UTC(2026, 7, 18, 12, 0, 0); let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'celilo-artifacts-')); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); /** Create a run directory whose files are `ageMs` old, with `bytes` of content. */ function makeRun(name: string, ageMs: number, bytes = 16): string { const dir = join(root, name); mkdirSync(dir, { recursive: true }); const file = join(dir, 'spa-failure.png'); writeFileSync(file, Buffer.alloc(bytes)); const when = new Date(NOW - ageMs); utimesSync(file, when, when); utimesSync(dir, when, when); return dir; } function survivors(): string[] { return readdirSync(root).sort(); } describe('pruneModuleArtifacts', () => { test('keeps everything inside the retention window', () => { makeRun('run-a', 1 * HOUR); makeRun('run-b', 23 * HOUR); const outcome = pruneModuleArtifacts(root, { now: NOW }); expect(outcome.removed).toEqual([]); expect(survivors()).toEqual(['run-a', 'run-b']); }); test('prunes what is older than the retention window', () => { makeRun('stale', 25 * HOUR); makeRun('fresh', 1 * HOUR); pruneModuleArtifacts(root, { now: NOW }); expect(survivors()).toEqual(['fresh']); }); test('the boundary is exclusive: exactly at the window, it stays', () => { // The off-by-one is the whole risk in a rule expressed as an inequality. makeRun('exactly-24h', ARTIFACT_RETENTION_MS); makeRun('a-ms-older', ARTIFACT_RETENTION_MS + 1); pruneModuleArtifacts(root, { now: NOW }); expect(survivors()).toEqual(['exactly-24h']); }); test('THE POINT: the first failure of a long streak survives', () => { // A 12-hour streak at the 15-minute health cadence — every run failing, // every one writing artifacts. The first set carries the original cause; // "keep the last N" would have discarded it hours ago. for (let quarterHour = 0; quarterHour <= 48; quarterHour++) { makeRun(`run-${String(quarterHour).padStart(3, '0')}`, quarterHour * 15 * 60 * 1000); } pruneModuleArtifacts(root, { now: NOW }); const kept = survivors(); expect(kept).toContain('run-048'); // the oldest — the first failure expect(kept).toContain('run-000'); // the most recent expect(kept.length).toBe(49); }); test('the size ceiling evicts oldest-first, and only down to the ceiling', () => { makeRun('oldest', 3 * HOUR, 1000); makeRun('middle', 2 * HOUR, 1000); makeRun('newest', 1 * HOUR, 1000); const outcome = pruneModuleArtifacts(root, { now: NOW, sizeCeilingBytes: 2500 }); // 3000 bytes exceeds 2500; dropping the oldest brings it to 2000. expect(survivors()).toEqual(['middle', 'newest']); expect(outcome.removed).toEqual([join(root, 'oldest')]); expect(outcome.retainedBytes).toBe(2000); }); test('a run still being written is aged by its newest file, not the directory', () => { // The directory's own mtime can lag a file rewritten in place, which // would let an in-flight run read as old enough to evict. const dir = join(root, 'in-flight'); mkdirSync(dir, { recursive: true }); const old = new Date(NOW - 30 * HOUR); utimesSync(dir, old, old); const stale = join(dir, 'old.txt'); writeFileSync(stale, 'x'); utimesSync(stale, old, old); writeFileSync(join(dir, 'just-written.png'), 'y'); // now pruneModuleArtifacts(root, { now: NOW }); expect(survivors()).toEqual(['in-flight']); }); test('a missing artifact root is not an error', () => { expect(() => pruneModuleArtifacts(join(root, 'never-created'), { now: NOW })).not.toThrow(); }); test('loose files beside the run directories are left alone', () => { writeFileSync(join(root, 'cookies.json'), '{}'); makeRun('stale', 30 * HOUR); pruneModuleArtifacts(root, { now: NOW }); expect(survivors()).toEqual(['cookies.json']); }); });