import crypto from "node:crypto" ;
import fs from "node:fs/promises" ;
import path from "node:path" ;
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest" ;
import { createFixtureSuite } from "../../test-utils/fixture-suite.js" ;
import { resolveMaintenanceConfigFromInput } from "./store-maintenance.js" ;
import {
capEntryCount,
getActiveSessionMaintenanceWarning,
loadSessionStore,
pruneStaleEntries,
rotateSessionFile,
} from "./store.js" ;
import type { SessionEntry } from "./types.js" ;
const DAY_MS = 24 * 60 * 60 * 1000 ;
const fixtureSuite = createFixtureSuite("openclaw-pruning-suite-" );
beforeAll(async () => {
await fixtureSuite.setup();
});
afterAll(async () => {
await fixtureSuite.cleanup();
});
function makeEntry(updatedAt: number): SessionEntry {
return { sessionId: crypto.randomUUID(), updatedAt };
}
function makeStore(entries: Array<[string, SessionEntry]>): Record<string, SessionEntry> {
return Object.fromEntries(entries);
}
// ---------------------------------------------------------------------------
// Unit tests — each function called with explicit override parameters.
// No config loading needed; overrides bypass resolveMaintenanceConfig().
// ---------------------------------------------------------------------------
describe("pruneStaleEntries" , () => {
it("removes entries older than maxAgeDays" , () => {
const now = Date.now();
const store = makeStore([
["old" , makeEntry(now - 31 * DAY_MS)],
["fresh" , makeEntry(now - 1 * DAY_MS)],
]);
const pruned = pruneStaleEntries(store, 30 * DAY_MS);
expect(pruned).toBe(1 );
expect(store.old).toBeUndefined();
expect(store.fresh).toBeDefined();
});
});
describe("capEntryCount" , () => {
it("over limit: keeps N most recent by updatedAt, deletes rest" , () => {
const now = Date.now();
const store = makeStore([
["oldest" , makeEntry(now - 4 * DAY_MS)],
["old" , makeEntry(now - 3 * DAY_MS)],
["mid" , makeEntry(now - 2 * DAY_MS)],
["recent" , makeEntry(now - 1 * DAY_MS)],
["newest" , makeEntry(now)],
]);
const evicted = capEntryCount(store, 3 );
expect(evicted).toBe(2 );
expect(Object.keys(store)).toHaveLength(3 );
expect(store.newest).toBeDefined();
expect(store.recent).toBeDefined();
expect(store.mid).toBeDefined();
expect(store.oldest).toBeUndefined();
expect(store.old).toBeUndefined();
});
});
describe("resolveMaintenanceConfigFromInput" , () => {
it("defaults to enforcing session maintenance" , () => {
const maintenance = resolveMaintenanceConfigFromInput();
expect(maintenance.mode).toBe("enforce" );
});
});
describe("getActiveSessionMaintenanceWarning" , () => {
it("warns when the active session is outside the retained recent entries" , () => {
const now = Date.now();
const store = makeStore([
["newest" , makeEntry(now)],
["recent" , makeEntry(now - 1 )],
["active" , makeEntry(now - 2 )],
["old" , makeEntry(now - 3 )],
]);
const warning = getActiveSessionMaintenanceWarning({
store,
activeSessionKey: "active" ,
pruneAfterMs: DAY_MS,
maxEntries: 2 ,
nowMs: now,
});
expect(warning?.wouldCap).toBe(true );
expect(warning?.wouldPrune).toBe(false );
});
it("preserves insertion order tie behavior from stable sorting" , () => {
const now = Date.now();
const store = makeStore([
["same-before" , makeEntry(now)],
["active" , makeEntry(now)],
["same-after" , makeEntry(now)],
]);
const warning = getActiveSessionMaintenanceWarning({
store,
activeSessionKey: "active" ,
pruneAfterMs: DAY_MS,
maxEntries: 1 ,
nowMs: now,
});
expect(warning?.wouldCap).toBe(true );
});
});
describe("rotateSessionFile" , () => {
let testDir: string;
let storePath: string;
beforeEach(async () => {
testDir = await fixtureSuite.createCaseDir("rotate" );
storePath = path.join(testDir, "sessions.json" );
});
it("file over maxBytes: copies to .bak.{timestamp}, returns true" , async () => {
const bigContent = "x" .repeat(200 );
await fs.writeFile(storePath, bigContent, "utf-8" );
const rotated = await rotateSessionFile(storePath, 100 );
expect(rotated).toBe(true );
await expect(fs.readFile(storePath, "utf-8" )).resolves.toBe(bigContent);
const files = await fs.readdir(testDir);
const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak." ));
expect(bakFiles).toHaveLength(1 );
const bakContent = await fs.readFile(path.join(testDir, bakFiles[0 ]), "utf-8" );
expect(bakContent).toBe(bigContent);
});
it("keeps live sessions readable if rotation is interrupted before the final save" , async () => {
const store = makeStore([["group:telegram:1" , makeEntry(Date.now())]]);
await fs.writeFile(storePath, JSON.stringify(store, null , 2 ), "utf-8" );
const rotated = await rotateSessionFile(storePath, 10 );
const loaded = loadSessionStore(storePath, {
skipCache: true ,
maintenanceConfig: {
mode: "enforce" ,
pruneAfterMs: DAY_MS,
maxEntries: 100 ,
rotateBytes: 1024 * 1024 ,
resetArchiveRetentionMs: null ,
maxDiskBytes: null ,
highWaterBytes: null ,
},
});
expect(rotated).toBe(true );
expect(loaded["group:telegram:1" ]?.sessionId).toBe(store["group:telegram:1" ].sessionId);
});
it("keeps an empty live store authoritative when stale backups exist" , async () => {
const staleStore = makeStore([["stale" , makeEntry(Date.now())]]);
await fs.writeFile(`${storePath}.bak.${Date.now()}`, JSON.stringify(staleStore), "utf-8" );
await fs.writeFile(storePath, "{}" , "utf-8" );
const loaded = loadSessionStore(storePath, {
skipCache: true ,
maintenanceConfig: {
mode: "enforce" ,
pruneAfterMs: DAY_MS,
maxEntries: 100 ,
rotateBytes: 1024 * 1024 ,
resetArchiveRetentionMs: null ,
maxDiskBytes: null ,
highWaterBytes: null ,
},
});
expect(loaded).toEqual({});
});
it("multiple rotations: only keeps 3 most recent .bak files" , async () => {
let now = Date.now();
const nowSpy = vi.spyOn(Date, "now" ).mockImplementation(() => (now += 5 ));
try {
// 4 rotations are enough to verify pruning to <=3 backups.
for (let i = 0 ; i < 4 ; i++) {
await fs.writeFile(storePath, `data-${i}-${"x" .repeat(100 )}`, "utf-8" );
await rotateSessionFile(storePath, 50 );
}
} finally {
nowSpy.mockRestore();
}
const files = await fs.readdir(testDir);
const bakFiles = files.filter((f) => f.startsWith("sessions.json.bak." )).toSorted();
expect(bakFiles.length).toBeLessThanOrEqual(3 );
});
});
Messung V0.5 in Prozent C=98 H=97 G=97
¤ Dauer der Verarbeitung: 0.9 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland